mirror of
https://github.com/evennia/evennia.git
synced 2026-03-16 21:06:30 +01:00
Add OnDemandHandler to support on-demand timers easier
This commit is contained in:
parent
9f1d14bae2
commit
42aba18ada
15 changed files with 1121 additions and 52 deletions
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
## main branch
|
||||
|
||||
- [Feature] Add `evennia.ON_DEMAND_HANDLER` for making it easier to implement
|
||||
timed element with the on-demand approach (Griatch)
|
||||
- [Fix] Remove `AMP_ENABLED` setting since it services no real purpose and
|
||||
erroring out on setting it would make it even less useful (Griatch).
|
||||
- [Fix] `services` command with no args would traceback (regression) (Griatch)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
# Changelog
|
||||
|
||||
## main branch
|
||||
|
||||
- [Feature] Add `evennia.ON_DEMAND_HANDLER` for making it easier to implement
|
||||
timed element with the on-demand approach (Griatch)
|
||||
- [Fix] Remove `AMP_ENABLED` setting since it services no real purpose and
|
||||
erroring out on setting it would make it even less useful (Griatch).
|
||||
- [Fix] `services` command with no args would traceback (regression) (Griatch)
|
||||
- [Feature][pull3412]: Make it possible to add custom webclient css in
|
||||
`webclient/css/custom.css`, same as for website (InspectorCaracal)
|
||||
- Doc fixes (InspectorCaracal, Griatch)
|
||||
|
||||
[pull3412]: https://github.com/evennia/evennia/pull/3412
|
||||
|
||||
## Evennia 3.1.1
|
||||
|
||||
Jan 14, 2024
|
||||
|
|
@ -9,8 +22,8 @@ Jan 14, 2024
|
|||
- [Fix][pull3405]: Fix/update of Godot client contrib to support Godot4 and
|
||||
latest Evennia portal changes (ChrisLR)
|
||||
- Updated doc on wiki install (InspectorCaracal)
|
||||
- Docstring fixes (bradleymarques)
|
||||
- Doc tutorial fixes
|
||||
- Docstring fixes (bradleymarques, Griatch)
|
||||
- Doc tutorial fixes (Griatch)
|
||||
|
||||
[pull3398]: https://github.com/evennia/evennia/pull/3398
|
||||
[pull3405]: https://github.com/evennia/evennia/pull/3405
|
||||
|
|
@ -35,7 +48,7 @@ Jan 8, 2024
|
|||
- [Fix][pull3382]: Make sure global scripts start properly on restart
|
||||
(InspectorCaracal)
|
||||
- [Fix][pull3394]: Fix time-of-day issue in ExpandedRoom contrib (jaborsh)
|
||||
- Doc fixes (homeofpoe, gas-public-wooden-clean, InspectorCaracal)
|
||||
- Doc fixes (homeofpoe, gas-public-wooden-clean, InspectorCaracal, Griatch)
|
||||
|
||||
[pull3373]: https://github.com/evennia/evennia/pull/3373
|
||||
[pull3375]: https://github.com/evennia/evennia/pull/3375
|
||||
|
|
@ -123,9 +136,9 @@ Dec 20, 2023
|
|||
other objects than oneself (InspectorCaracal)
|
||||
- [Fix][pull3361]: Fix of monitoring Attributes with categories (scyfris)
|
||||
- Docs & docstrings: Lots of Typo and other fixes (iLPdev, InspectorCaracal, jaborsh,
|
||||
HouseOfPoe etc)
|
||||
HouseOfPoe, Griatch etc)
|
||||
- Beginner tutorial: Cleanup and starting earlier with explaining how to add to
|
||||
the default cmdsets.
|
||||
the default cmdsets (Griatch).
|
||||
|
||||
[pull3267]: https://github.com/evennia/evennia/pull/3267
|
||||
[pull3270]: https://github.com/evennia/evennia/pull/3270
|
||||
|
|
@ -174,8 +187,8 @@ Sept 3, 2023
|
|||
- Fix: Traceback when printing CounterTrait contrib objects. (InspectorCaracal)
|
||||
- Fix: Typo in evadventure twitch combat's call of `create_combathandler`.
|
||||
- Docs: Fix bug in evadventure equipmenthandler blocking creation of npcs.
|
||||
in-game.
|
||||
- Docs: Plenty of typo fixes (iLPDev, moldikins, others)
|
||||
in-game (Griatch).
|
||||
- Docs: Plenty of typo fixes (iLPDev, moldikins, Griatch), others)
|
||||
|
||||
## Evennia 2.2.0
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ EvMore.md
|
|||
EvTable.md
|
||||
FuncParser.md
|
||||
MonitorHandler.md
|
||||
OnDemandHandler.md
|
||||
TickerHandler.md
|
||||
Signals.md
|
||||
```
|
||||
|
|
|
|||
174
docs/source/Components/OnDemandHandler.md
Normal file
174
docs/source/Components/OnDemandHandler.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# OnDemandHandler
|
||||
|
||||
This handler offers help for implementing on-demand state changes. On-demand means that the state won't be computed until the player _actually looks for it_. Until they do, nothing happens. This is the most compute-efficient way to handle your systems and you should consider using this style of system whenever you can.
|
||||
|
||||
Take for example a gardening system. A player goes to a room and plants a seed. After a certain time, that plant will then move through a set of stages; it will move from "seedling" to 'sprout' to 'flowering' and then on to 'wilting' and eventually 'dead'.
|
||||
|
||||
Now, you _could_ use `utils.delay` to track each phase, or use the [TickerHandler](./TickerHandler.md) to tick the flower. You could even use a [Script](./Scripts.md) on the flower.
|
||||
1. The ticker/task/Script would automatically fire at regular intervals to update the plant through its stages.
|
||||
2. Whenever a player comes to the room, the state is already updated on the flower, so they just read the state.
|
||||
|
||||
This will work fine, but if no one comes back to that room, that's a lot of updating that no one will see. While maybe not a big deal for a single player, what if you have flowers in thousands of rooms, all growing indepedently? Or some even more complex system requiring calculation on every state change. You should avoid spending computing on things that bring nothing extra to your player base.
|
||||
|
||||
Using the The on-demand style would instead work like this for the flower:
|
||||
1. When the player plants the seed, we register a new on-demand task with the `OnDemandHandler` (described below). This registes _the current timestamp_ when the plant starts to grow.
|
||||
2. When a player enters the room and/or looks at the plant, _then_ (and only then) we call the `OnDemandHandler` to see what state the flower it's in. It will then use the _current time_ to figure out how much time passed and which state the plant is thus in. Until someone looks, the plant is in its previous found state, because no-one needed to know until then. Same thing, if some other system needs to know this - they just figure out the state on the fly.
|
||||
|
||||
## A blooming flower using the OnDemandHandler
|
||||
|
||||
This handler is found as `evennia.ON_DEMAND_HANDLER`. It is meant to be integrated into your other code. Here's an example of a flower that
|
||||
|
||||
```python
|
||||
# e.g. in mygame/typeclasses/objects.py
|
||||
|
||||
from evennia import ON_DEMAND_HANDLER
|
||||
|
||||
# ...
|
||||
|
||||
class Flower(Object):
|
||||
|
||||
def at_object_creation(self):
|
||||
|
||||
minute = 60
|
||||
hour = minute * 60
|
||||
|
||||
ON_DEMAND_HANDLER.add(
|
||||
self,
|
||||
category="plantgrowth"
|
||||
stages={
|
||||
0: "seedling",
|
||||
10 * minute: "sprout",
|
||||
5 * hour: "flowering",
|
||||
10 * hour: "wilting",
|
||||
12 * hour: "dead"
|
||||
})
|
||||
|
||||
def at_desc(self, looker):
|
||||
"""
|
||||
Called whenever someone looks at this object
|
||||
"""
|
||||
stage = ON_DEMAND_HANDLER.get_state(self, category="plantgrowth")
|
||||
|
||||
match stage:
|
||||
case "seedling":
|
||||
return "There's nothing to see. Nothing has grown yet."
|
||||
case "sprout":
|
||||
return "A small delicate sprout has emerged!"
|
||||
case "flowering":
|
||||
return f"A beautiful {self.name}!"
|
||||
case "wilting":
|
||||
return f"This {self.name} has seen better days."
|
||||
case "dead":
|
||||
# it's dead and gone. Stop and delete
|
||||
ON_DEMAND_HANDLER.remove(self, category="plantgrowth")
|
||||
self.delete()
|
||||
```
|
||||
|
||||
|
||||
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 (a very quickly growing rose!).
|
||||
|
||||
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.
|
||||
|
||||
The on-demand handler's tasks survive a reload and will properly account for downtime.
|
||||
|
||||
## More usage examples
|
||||
|
||||
The [OnDemandHandler API](evennia.scripts.ondemandhandler.OnDemandHandler) describes how to use the handler in detail. While it's available as `evennia.ON_DEMAND_HANDLER`, its code is located in `evennia.scripts.ondemandhandler.py`.
|
||||
|
||||
```python
|
||||
from evennia import ON_DEMAND_HANDLER
|
||||
|
||||
ON_DEMAND_HANDLER.add("key", category=None, stages=None)
|
||||
time_passed = ON_DEMAND_HANDLER.get_dt("key", category=None)
|
||||
current_state = ON_DEMAND_HANDLER.get_stage("key", category=None)
|
||||
|
||||
# remove things
|
||||
ON_DEMAND_HANDLER.remove("key", category=None)
|
||||
ON_DEMAND_HANDLER.clear(cateogory="category") #clear all with category
|
||||
```
|
||||
|
||||
- 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) before next stage begins. 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. 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.
|
||||
- `.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
|
||||
|
||||
task1 = OnDemandTask("key1", {0: "state1", 100: "state2"})
|
||||
task2 = OnDemandTask("key2", category)
|
||||
|
||||
ON_DEMAND_HANDLER.batch_add(task1, task2)
|
||||
|
||||
# get tasks back
|
||||
task = ON_DEMAND_HANDLER.get("key1")
|
||||
|
||||
# batch-delete (deactivate) from handler
|
||||
ON_DEMAND_HANDLER.batch_remove(task1, task2)
|
||||
```
|
||||
|
||||
### Looping repeatedly
|
||||
|
||||
Normally, when a sequence of `stages` have been cycled through, the task will just
|
||||
|
||||
|
||||
`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:
|
||||
|
||||
```python
|
||||
from evennia import ON_DEMAND_HANDLER, OnDemandTask
|
||||
|
||||
ON_DEMAND_HANDLER.add(
|
||||
"trap_state",
|
||||
stages={
|
||||
0: "harmless",
|
||||
50: "solvable",
|
||||
100: "primed",
|
||||
200: "deadly",
|
||||
250: ("_reset", OnDemandTask.stagefunc_loop)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
This is a trap state that loops through its states depending on timing. Note that the looping helper callable will _immediately_ reset the cycle back to the first stage, so the last stage will never be visible to the player/game system. So it's a good (if optional) idea to name it with `_*` to remember this is a 'virtual' stage.
|
||||
|
||||
The `OnDemandTask` task instance has a `.iterations` variable that will go up by one for every loop.
|
||||
|
||||
If the state is not checked for a long time, the looping function will correctly update the `.iterations` of the task it would have used so far and figure out where in the cycle it is right now.
|
||||
|
||||
### Bouncing back and forth
|
||||
|
||||
`evennia.OnDemandTask.stagefunc_bounce` is an included static-method callable you can use to 'bounce' the sequence of stages. That is, it will cycle to the end of the cycle and then reverse direction and cycle through the sequence in reverse.
|
||||
|
||||
To make this repreat indefinitely, you need to put the callables at both ends of the list:
|
||||
|
||||
```python
|
||||
from evennia import ON_DEMAND_HANDLER, OnDemandTask
|
||||
|
||||
ON_DEMAND_HANDLER.add(
|
||||
"cycling reactor",
|
||||
"nuclear",
|
||||
stages={
|
||||
0: ("cold", OnDemandTask.stagefunc_bounce),
|
||||
150: "luke warm",
|
||||
300: "warm",
|
||||
450: "hot"
|
||||
600: ("HOT!", OnDemandTask.stagefunc_bounce)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
This will cycle
|
||||
|
||||
cold -> luke warm -> warm -> hot -> HOT!
|
||||
|
||||
before reversing and go back:
|
||||
|
||||
HOT! -> hot -> warm -> luke warm -> cold
|
||||
|
||||
Over and over. The `OnDemandTask` instance has an `.iterations` property that will step up by one every time the sequence reverses.
|
||||
|
||||
If the state is not checked for a long time, the bounce function will correctly update the `.iterations` property to the amount of iterations it would have done in that time, and figure out where in the cycle it must be right now.
|
||||
|
|
@ -171,7 +171,32 @@ if trait1 > trait2:
|
|||
|
||||
```
|
||||
|
||||
## Static trait
|
||||
### Trait
|
||||
|
||||
A single value of any type.
|
||||
|
||||
This is the 'base' Trait, meant to inherit from if you want to invent
|
||||
trait-types from scratch (most of the time you'll probably inherit from some of
|
||||
the more advanced trait-type classes though).
|
||||
|
||||
Unlike other Trait-types, the single `.value` property of the base `Trait` can
|
||||
be editied. The value can hold any data that can be stored in an Attribute. If
|
||||
it's an integer/float you can do arithmetic with it, but otherwise this acts just
|
||||
like a glorified Attribute.
|
||||
|
||||
|
||||
```python
|
||||
> obj.traits.add("mytrait", "My Trait", trait_type="trait", value=30)
|
||||
> obj.traits.mytrait.value
|
||||
30
|
||||
|
||||
> obj.traits.mytrait.value = "stringvalue"
|
||||
> obj.traits.mytrait.value
|
||||
"stringvalue"
|
||||
|
||||
```
|
||||
|
||||
### Static trait
|
||||
|
||||
`value = base + mod`
|
||||
|
||||
|
|
@ -365,31 +390,6 @@ get how filled it is as a percentage etc.
|
|||
The `.rate` is particularly relevant for gauges - useful for everything
|
||||
from poison slowly draining your health, to resting gradually increasing it.
|
||||
|
||||
### Trait
|
||||
|
||||
A single value of any type.
|
||||
|
||||
This is the 'base' Trait, meant to inherit from if you want to invent
|
||||
trait-types from scratch (most of the time you'll probably inherit from some of
|
||||
the more advanced trait-type classes though).
|
||||
|
||||
Unlike other Trait-types, the single `.value` property of the base `Trait` can
|
||||
be editied. The value can hold any data that can be stored in an Attribute. If
|
||||
it's an integer/float you can do arithmetic with it, but otherwise this acts just
|
||||
like a glorified Attribute.
|
||||
|
||||
|
||||
```python
|
||||
> obj.traits.add("mytrait", "My Trait", trait_type="trait", value=30)
|
||||
> obj.traits.mytrait.value
|
||||
30
|
||||
|
||||
> obj.traits.mytrait.value = "stringvalue"
|
||||
> obj.traits.mytrait.value
|
||||
"stringvalue"
|
||||
|
||||
```
|
||||
|
||||
## Expanding with your own Traits
|
||||
|
||||
A Trait is a class inhering from `evennia.contrib.rpg.traits.Trait` (or from one of
|
||||
|
|
|
|||
|
|
@ -147,7 +147,6 @@ EVENNIA_ADMIN = True
|
|||
# operating between two processes on the same machine. You usually don't need to
|
||||
# change this unless you cannot use the default AMP port/host for
|
||||
# whatever reason.
|
||||
AMP_ENABLED = True
|
||||
AMP_HOST = "localhost"
|
||||
AMP_PORT = 4006
|
||||
AMP_INTERFACE = "127.0.0.1"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ evennia.scripts
|
|||
evennia.scripts.manager
|
||||
evennia.scripts.models
|
||||
evennia.scripts.monitorhandler
|
||||
evennia.scripts.ondemandhandler
|
||||
evennia.scripts.scripthandler
|
||||
evennia.scripts.scripts
|
||||
evennia.scripts.taskhandler
|
||||
|
|
|
|||
10
docs/source/api/evennia.scripts.ondemandhandler.md
Normal file
10
docs/source/api/evennia.scripts.ondemandhandler.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
```{eval-rst}
|
||||
evennia.scripts.ondemandhandler
|
||||
======================================
|
||||
|
||||
.. automodule:: evennia.scripts.ondemandhandler
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
```
|
||||
|
|
@ -97,6 +97,7 @@ EvMore = None
|
|||
ANSIString = None
|
||||
signals = None
|
||||
FuncParser = None
|
||||
OnDemandTask = None
|
||||
|
||||
# Handlers
|
||||
SESSION_HANDLER = None
|
||||
|
|
@ -105,6 +106,7 @@ SERVER_SESSION_HANDLER = None
|
|||
TASK_HANDLER = None
|
||||
TICKER_HANDLER = None
|
||||
MONITOR_HANDLER = None
|
||||
ON_DEMAND_HANDLER = None
|
||||
|
||||
# Containers
|
||||
GLOBAL_SCRIPTS = None
|
||||
|
|
@ -169,11 +171,11 @@ def _init(portal_mode=False):
|
|||
global search_object, search_script, search_account, search_channel
|
||||
global search_help, search_tag, search_message
|
||||
global create_object, create_script, create_account, create_channel
|
||||
global create_message, create_help_entry
|
||||
global create_message, create_help_entry, OnDemandTask
|
||||
global signals
|
||||
global settings, lockfuncs, logger, utils, gametime, ansi, spawn, managers
|
||||
global contrib, TICKER_HANDLER, MONITOR_HANDLER, SESSION_HANDLER, PROCESS_ID
|
||||
global TASK_HANDLER, PORTAL_SESSION_HANDLER, SERVER_SESSION_HANDLER
|
||||
global TASK_HANDLER, PORTAL_SESSION_HANDLER, SERVER_SESSION_HANDLER, ON_DEMAND_HANDLER
|
||||
global GLOBAL_SCRIPTS, OPTION_CLASSES, EVENNIA_PORTAL_SERVICE, EVENNIA_SERVER_SERVICE, TWISTED_APPLICATION
|
||||
global EvMenu, EvTable, EvForm, EvMore, EvEditor
|
||||
global ANSIString, FuncParser
|
||||
|
|
@ -196,15 +198,11 @@ def _init(portal_mode=False):
|
|||
from .comms.models import ChannelDB, Msg
|
||||
from .locks import lockfuncs
|
||||
from .objects.models import ObjectDB
|
||||
from .objects.objects import (
|
||||
DefaultCharacter,
|
||||
DefaultExit,
|
||||
DefaultObject,
|
||||
DefaultRoom,
|
||||
)
|
||||
from .objects.objects import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom
|
||||
from .prototypes.spawner import spawn
|
||||
from .scripts.models import ScriptDB
|
||||
from .scripts.monitorhandler import MONITOR_HANDLER
|
||||
from .scripts.ondemandhandler import ON_DEMAND_HANDLER, OnDemandTask
|
||||
from .scripts.scripts import DefaultScript
|
||||
from .scripts.taskhandler import TASK_HANDLER
|
||||
from .scripts.tickerhandler import TICKER_HANDLER
|
||||
|
|
|
|||
542
evennia/scripts/ondemandhandler.py
Normal file
542
evennia/scripts/ondemandhandler.py
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
"""
|
||||
Helper to handle on-demand requests, allowing a system to change state only when a player or system
|
||||
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.
|
||||
|
||||
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
|
||||
when they are in the same room as it (so it can be incorporated in the room description). This is
|
||||
where on-demand handling comes in.
|
||||
|
||||
This is the basic principle, using the flowering system as an example.
|
||||
|
||||
1. Someone plants a seed in a room (could also be automated). The seed is in a "seedling" state.
|
||||
We store the time it was planted (this is the important bit).
|
||||
2. A player enters the room or looks at the plant. We check the time it was planted, and calculate
|
||||
how much time has passed since it was planted. If enough time has passed, we change the state to
|
||||
"sprouting" and probably change its description to reflect this.
|
||||
3. If a player looks at the plant and not enough time has passed, it keeps the last updated state.
|
||||
4. Eventually, it will be bloom time, and the plant will change to a "blooming" state when the
|
||||
player looks.
|
||||
5. If no player ever comes around to look at the plant, it will never change state, and if they show
|
||||
up after a long time, it may not show as a "wilted" state or be outright deleted when observed,
|
||||
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.
|
||||
|
||||
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
|
||||
in the room, they should get a message like 'the plant suddenly blooms' (or, more commonly, for
|
||||
messages like 'you are feeling hungry'). For this you still probably need to use one of Evennia's
|
||||
built-in timers or tickers instead. But most of the time you should really consider using on-demand
|
||||
handling instead.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
|
||||
from evennia import ON_DEMAND_HANDLER
|
||||
|
||||
# create a new on-demand task
|
||||
|
||||
flower = create_object(Flower, key="rose")
|
||||
|
||||
ON_DEMAND_HANDLER.add_task(
|
||||
flower, category="flowering",
|
||||
stages={0: "seedling", 120: "sprouting",
|
||||
300: "blooming", 600: "wilted", 700: "dead"})
|
||||
|
||||
# later, when we want to check the state of the plant (e.g. in a command),
|
||||
|
||||
state = ON_DEMAND_HANDLER.get_stage("flowering", last_checked=plant.planted_time)
|
||||
|
||||
```
|
||||
|
||||
|
||||
"""
|
||||
|
||||
from evennia.server.models import ServerConfig
|
||||
from evennia.utils import logger
|
||||
from evennia.utils.utils import is_iter
|
||||
|
||||
_RUNTIME = None
|
||||
|
||||
ON_DEMAND_HANDLER = None
|
||||
|
||||
|
||||
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.
|
||||
|
||||
"""
|
||||
|
||||
# useful stage-functions. Use with OnDemandTask.endfunc_stop etc
|
||||
|
||||
@staticmethod
|
||||
def runtime():
|
||||
"""
|
||||
Wraps the gametime.runtime() function.
|
||||
|
||||
Need to import here to avoid circular imports during server reboot.
|
||||
It's a callable to allow easier unit testing.
|
||||
|
||||
"""
|
||||
global _RUNTIME
|
||||
if not _RUNTIME:
|
||||
from evennia.utils.gametime import runtime as _RUNTIME
|
||||
return _RUNTIME()
|
||||
|
||||
@staticmethod
|
||||
def stagefunc_loop(task):
|
||||
"""
|
||||
Attach this to the last stage to have the task start over from
|
||||
the beginning
|
||||
|
||||
Example:
|
||||
stages = {0: "seedling", 120: "flowering", 300: "dead", ("_loop",
|
||||
OnDemandTask.stagefunc_loop)}
|
||||
|
||||
Note that the "respawn" state will never actually be visible as a state to
|
||||
the user, instead once it reaches this state, it will *immediately* loop
|
||||
and the new looped state will be shown and returned to the user. So it
|
||||
can an idea to mark that end state with a `_` just to indicate this fact.
|
||||
|
||||
"""
|
||||
|
||||
now = OnDemandTask.runtime()
|
||||
original_start_time = (
|
||||
task.start_time
|
||||
) # this can be set on start or previous call of this func
|
||||
dts = list(task.stages.keys())
|
||||
total_dt = max(dts) - min(dts)
|
||||
|
||||
# figure out how many times we've looped since last start-time was set
|
||||
task.iterations += (now - original_start_time) // total_dt
|
||||
# figure out how far we are into the current loop.
|
||||
current_loop_time = (now - original_start_time) % total_dt
|
||||
# We need to adjust the start_time to the start of the current loop
|
||||
task.start_time = now - current_loop_time
|
||||
|
||||
@staticmethod
|
||||
def stagefunc_bounce(task):
|
||||
"""
|
||||
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
|
||||
for the bounce to continue indefinitely.
|
||||
|
||||
Example:
|
||||
stages = {0: ("cool", OnDemandTask.stagefunc_bounce),
|
||||
50: "lukewarm",
|
||||
150: "warm",
|
||||
300: "hot",
|
||||
300: ("HOT!", OnDemandTask.stagefunc_bounce)}
|
||||
|
||||
"""
|
||||
now = OnDemandTask.runtime()
|
||||
original_start_time = (
|
||||
task.start_time
|
||||
) # this can be set on start or previous call of this func
|
||||
dts = list(task.stages.keys())
|
||||
max_dt = max(dts)
|
||||
total_dt = max_dt - min(dts)
|
||||
task.iterations += (now - original_start_time) // total_dt
|
||||
current_loop_time = (now - original_start_time) % total_dt
|
||||
task.start_time = now - current_loop_time
|
||||
|
||||
if task.iterations > 0:
|
||||
# reverse the stages
|
||||
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_stage_function = None
|
||||
|
||||
def __init__(self, key, category, stages=None, autostart=True):
|
||||
"""
|
||||
Args:
|
||||
key (str): A unique identifier for the task.
|
||||
stages (dict, optional): A dictionary `{dt: str}` or `{int or float: (str, callable)}`
|
||||
of time-deltas (in seconds) and the stage name they represent. If the value is a
|
||||
tuple, the first element is the name of the stage and the second is a callable
|
||||
that will be called when that stage is *first* reached. Warning: This callable
|
||||
is *only* triggered if the stage is actually checked/retrieved while the task is
|
||||
in that stage checks - it's _not_ guaranteed to be called, even if the task
|
||||
time-wise goes through all its stages. Each callable must be picklable (so normally
|
||||
it should be a stand-alone function), and takes one argument - this OnDemandTask,
|
||||
which it can be modified in-place as needed. This can be used to loop a task or do
|
||||
other changes to the task.
|
||||
autostart (bool, optional): If `last_checked` is `None`, and this is `False`, then the
|
||||
time will not start counting until the first call of `get_dt` or `get_stage`. If
|
||||
`True`, creating the task will immediately make a hidden check and start the timer.
|
||||
|
||||
Examples:
|
||||
|
||||
stages = {0: "seedling",
|
||||
120: "sprouting",
|
||||
300: "blooming",
|
||||
600: "wilted",
|
||||
700: "dead"
|
||||
}
|
||||
|
||||
"""
|
||||
self.key = key
|
||||
self.category = category
|
||||
self.start_time = None
|
||||
self.last_stage = None
|
||||
self.iterations = 0 # only used with looping staging functions
|
||||
|
||||
self.stages = None
|
||||
|
||||
if isinstance(stages, dict):
|
||||
# sort the stages by ending time, inserting each state as {dt: (statename, callable)}
|
||||
_stages = {}
|
||||
for dt, tup in stages.items():
|
||||
if is_iter(tup):
|
||||
if len(tup) != 2:
|
||||
raise ValueError(
|
||||
"Each stage must be a tuple (name, callable) or a name-string."
|
||||
)
|
||||
if not callable(tup[1]):
|
||||
raise ValueError(
|
||||
"The second element of each stage-tuple must be a callable."
|
||||
)
|
||||
else:
|
||||
tup = (tup, None)
|
||||
_stages[dt] = tup
|
||||
|
||||
self.stages = {dt: tup for dt, tup in sorted(_stages.items(), reverse=True)}
|
||||
|
||||
self.check(autostart=autostart)
|
||||
|
||||
def __str__(self):
|
||||
"""Note that we don't check the state here"""
|
||||
# we visualize stages with ascending key order
|
||||
dt, stage = self.check(autostart=False)
|
||||
return f"OnDemandTask({self.key}[{self.category}] (dt={dt}s), stage={stage})"
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, OnDemandTask):
|
||||
return False
|
||||
return (self.key, self.category) == (other.key, other.category)
|
||||
|
||||
def check(self, autostart=True):
|
||||
"""
|
||||
Check the current stage of the task and return the time-delta to the next stage.
|
||||
|
||||
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.
|
||||
|
||||
Returns:
|
||||
tuple: A tuple (dt, stage) where `dt` is the time-delta (in seconds) since the test
|
||||
started (or since it started its latest iteration). and `stage` is the name of the
|
||||
current stage. If no stages are defined, `stage` will always be `None`. Use `get_dt` and
|
||||
`get_stage` to get only one of these values.
|
||||
|
||||
"""
|
||||
|
||||
def _find_stage(delta_dt, _rerun=False):
|
||||
if not self.stages:
|
||||
return None
|
||||
|
||||
for dt, (stage, stage_func) in self.stages.items():
|
||||
if delta_dt < dt:
|
||||
continue
|
||||
|
||||
if autostart and stage != self.last_stage and not _rerun:
|
||||
self.last_stage = stage
|
||||
|
||||
if stage_func:
|
||||
try:
|
||||
stage_func(self)
|
||||
except Exception as err:
|
||||
logger.log_trace(
|
||||
f"Error getting stage of on-demand task {self} "
|
||||
f"(last_stage: {self.last_stage}, trying to call stage-func "
|
||||
f"{stage_func}: {err}"
|
||||
)
|
||||
else:
|
||||
# rerun the check in case the endfunc changed things
|
||||
return _find_stage(delta_dt, _rerun=True)
|
||||
return stage
|
||||
|
||||
def _find_dt(self, autostart=False):
|
||||
if self.start_time is None:
|
||||
if autostart:
|
||||
# start timer
|
||||
self.start_time = now
|
||||
dt = 0
|
||||
else:
|
||||
dt = now - self.start_time
|
||||
return dt
|
||||
|
||||
now = OnDemandTask.runtime()
|
||||
|
||||
dt = _find_dt(self, autostart=autostart)
|
||||
|
||||
# we must always fetch the stage since a stage_func may fire
|
||||
stage = _find_stage(dt)
|
||||
|
||||
# need to fetch dt again in case stage_func changed it
|
||||
dt = _find_dt(self, autostart=autostart)
|
||||
|
||||
return dt, stage
|
||||
|
||||
def get_dt(self):
|
||||
"""
|
||||
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.
|
||||
|
||||
"""
|
||||
|
||||
return self.check()[0]
|
||||
|
||||
def get_stage(self):
|
||||
"""
|
||||
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.
|
||||
|
||||
"""
|
||||
return self.check()[1]
|
||||
|
||||
|
||||
class OnDemandHandler:
|
||||
"""
|
||||
A singleton handler for managing on-demand state changes. Its main function is to persistently
|
||||
track the time (in seconds) between a state change and the next. How you make use of this
|
||||
information is up to your particular system.
|
||||
|
||||
Contrary to just using the `time` module, this will also account for server restarts.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.tasks = dict()
|
||||
|
||||
def load(self):
|
||||
"""
|
||||
Load the on-demand timers from ServerConfig storage.
|
||||
|
||||
This should be automatically called when Evennia starts.
|
||||
|
||||
"""
|
||||
self.tasks = ServerConfig.objects.conf("on_demand_timers", 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)
|
||||
|
||||
def _build_key(self, key, category):
|
||||
"""
|
||||
Build a unique key for the task.
|
||||
|
||||
Args:
|
||||
key (str, callable, OnDemandTask or Object): The task key. If callable, it 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 build the internal storage key.
|
||||
category (str or callable): The task category. If callable, it will be called without arguments.
|
||||
|
||||
Returns:
|
||||
tuple (str, str or None): The unique key.
|
||||
|
||||
"""
|
||||
if isinstance(key, OnDemandTask):
|
||||
return (key.key, key.category)
|
||||
|
||||
return (
|
||||
str(key() if callable(key) else key),
|
||||
category() if callable(category) else str(category) if category is not None else None,
|
||||
)
|
||||
|
||||
def add(self, key, category=None, stages=None, autostart=True):
|
||||
"""
|
||||
Add a new on-demand task.
|
||||
|
||||
Args:
|
||||
key (str, callable, OnDemandTask or Object): A unique identifier for the task. If this
|
||||
is a callable, it will be called without arguments. If a db-Object, it will be
|
||||
converted to a string representation (which will include its (#dbref). If an
|
||||
`OnDemandTask`, then all other arguments are ignored and the task is simply added
|
||||
as-is.
|
||||
category (str or callable, optional): A category to group the task under. If given, it
|
||||
must also be given when checking the task.
|
||||
stages (dict, optional): A dictionary {dt: str}, of time-deltas (in seconds) and the
|
||||
stage which should be entered after that much time has passed. autostart (bool,
|
||||
optional): If `True`, creating the task will immediately make a hidden
|
||||
check and start the timer.
|
||||
|
||||
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.
|
||||
|
||||
"""
|
||||
if isinstance(key, OnDemandTask):
|
||||
self.tasks[self._build_key(key.key, key.category)] = key
|
||||
return key
|
||||
task = OnDemandTask(key, category, stages, autostart=autostart)
|
||||
self.tasks[self._build_key(key, category)] = task
|
||||
return task
|
||||
|
||||
def batch_add(self, *tasks):
|
||||
"""
|
||||
Add multiple on-demand tasks at once.
|
||||
|
||||
Args:
|
||||
*tasks (OnDemandTask): A set of OnDemandTasks to add.
|
||||
|
||||
"""
|
||||
for task in tasks:
|
||||
self.tasks[self._build_key(task.key, task.category)] = task
|
||||
|
||||
def remove(self, key, category=None):
|
||||
"""
|
||||
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.
|
||||
category (str or callable, optional): The category of the task.
|
||||
|
||||
Returns:
|
||||
OnDemandTask or None: The removed task, or `None` if no task was found.
|
||||
|
||||
"""
|
||||
return self.tasks.pop(self._build_key(key, category), None)
|
||||
|
||||
def batch_remove(self, *keys, category=None):
|
||||
"""
|
||||
Remove multiple on-demand tasks at once, potentially within a given category.
|
||||
|
||||
Args:
|
||||
*keys (str, callable, OnDemandTask or Object): The unique identifiers for the tasks. 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 tasks.
|
||||
|
||||
"""
|
||||
for key in keys:
|
||||
self.remove(key, category=category)
|
||||
|
||||
def all(self, category=None, all_on_none=True):
|
||||
"""
|
||||
Get all on-demand tasks.
|
||||
|
||||
Args:
|
||||
category (str, optional): The category of the tasks.
|
||||
all_on_none (bool, optional): Determines what to return if `category` is `None`.
|
||||
If `True`, all tasks will be returned. If `False`, only tasks without a category
|
||||
will be returned.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary of all on-demand task, on the form `{(key, category): task), ...}`.
|
||||
Use `task.get_dt()` or `task.get_stage()` to get the time-delta or stage of each task
|
||||
manually.
|
||||
|
||||
"""
|
||||
if category is None and all_on_none:
|
||||
# return all
|
||||
return self.tasks
|
||||
|
||||
# filter by category (treat no-category as its own category)
|
||||
return {keytuple: task for keytuple, task in self.tasks.items() if keytuple[1] == category}
|
||||
|
||||
def clear(self, category=None, all_on_none=True):
|
||||
"""
|
||||
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.
|
||||
|
||||
"""
|
||||
if category is None and all_on_none:
|
||||
# clear all
|
||||
self.tasks = {}
|
||||
|
||||
# filter and clear only those matching the category
|
||||
self.tasks = {
|
||||
keytuple: task for keytuple, task in self.tasks.items() if keytuple[1] != category
|
||||
}
|
||||
|
||||
def get(self, key, category=None):
|
||||
"""
|
||||
Get an on-demand task. This will _not_ check it.
|
||||
|
||||
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).
|
||||
|
||||
category (str, optional): The category of the task. If unset, this will only return
|
||||
tasks with no category.
|
||||
|
||||
Returns:
|
||||
OnDemandTask or None: The task, or `None` if no task was found.
|
||||
|
||||
"""
|
||||
return self.tasks.get(self._build_key(key, category))
|
||||
|
||||
def get_dt(self, key, category=None):
|
||||
"""
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
def get_stage(self, key, category=None):
|
||||
"""
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
# Create singleton
|
||||
ON_DEMAND_HANDLER = OnDemandHandler()
|
||||
|
|
@ -5,13 +5,12 @@ Module containing the task handler for Evennia deferred tasks, persistent or not
|
|||
from datetime import datetime, timedelta
|
||||
from pickle import PickleError
|
||||
|
||||
from twisted.internet import reactor
|
||||
from twisted.internet.defer import CancelledError as DefCancelledError
|
||||
from twisted.internet.task import deferLater
|
||||
|
||||
from evennia.server.models import ServerConfig
|
||||
from evennia.utils.dbserialize import dbserialize, dbunserialize
|
||||
from evennia.utils.logger import log_err
|
||||
from twisted.internet import reactor
|
||||
from twisted.internet.defer import CancelledError as DefCancelledError
|
||||
from twisted.internet.task import deferLater
|
||||
|
||||
TASK_HANDLER = None
|
||||
|
||||
|
|
@ -204,7 +203,7 @@ class TaskHandlerTask:
|
|||
return self.task_id
|
||||
|
||||
|
||||
class TaskHandler(object):
|
||||
class TaskHandler:
|
||||
|
||||
"""A light singleton wrapper allowing to access permanent tasks.
|
||||
|
||||
|
|
@ -236,7 +235,7 @@ class TaskHandler(object):
|
|||
|
||||
"""
|
||||
to_save = False
|
||||
value = ServerConfig.objects.conf("delayed_tasks", default={})
|
||||
value = ServerConfig.objects.conf("delayed_tasks", default=dict)
|
||||
if isinstance(value, str):
|
||||
tasks = dbunserialize(value)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -11,11 +11,12 @@ from evennia.objects.objects import DefaultObject
|
|||
from evennia.scripts.manager import ScriptDBManager
|
||||
from evennia.scripts.models import ObjectDoesNotExist, ScriptDB
|
||||
from evennia.scripts.monitorhandler import MonitorHandler
|
||||
from evennia.scripts.ondemandhandler import OnDemandHandler, OnDemandTask
|
||||
from evennia.scripts.scripts import DoNothing, ExtendedLoopingCall
|
||||
from evennia.scripts.tickerhandler import TickerHandler
|
||||
from evennia.utils.create import create_script
|
||||
from evennia.utils.dbserialize import dbserialize
|
||||
from evennia.utils.test_resources import BaseEvenniaTest
|
||||
from evennia.utils.test_resources import BaseEvenniaTest, EvenniaTest
|
||||
from parameterized import parameterized
|
||||
|
||||
|
||||
|
|
@ -309,3 +310,320 @@ class TestMonitorHandler(TestCase):
|
|||
"""Remove attribute from the handler and assert that it is gone"""
|
||||
self.handler.remove(obj, fieldname, idstring=idstring, category=category)
|
||||
self.assertEquals(self.handler.monitors[index][name], {})
|
||||
|
||||
|
||||
class TestOnDemandTask(EvenniaTest):
|
||||
"""
|
||||
Test the OnDemandTask class.
|
||||
|
||||
"""
|
||||
|
||||
@mock.patch("evennia.scripts.ondemandhandler.OnDemandTask.runtime")
|
||||
def test_no_stages__no_autostart(self, mock_runtime):
|
||||
mock_runtime.return_value = 1000
|
||||
task = OnDemandTask("rose", "flower", autostart=False)
|
||||
|
||||
self.assertEqual(task.key, "rose")
|
||||
self.assertEqual(task.category, "flower")
|
||||
self.assertEqual(task.start_time, None)
|
||||
|
||||
self.assertEqual(str(task), "OnDemandTask(rose[flower] (dt=0s), stage=None)")
|
||||
self.assertEqual(task.get_dt(), 0)
|
||||
self.assertEqual(task.start_time, 1000)
|
||||
|
||||
mock_runtime.return_value = 3000
|
||||
|
||||
self.assertEqual(task.get_dt(), 2000)
|
||||
self.assertEqual(task.get_stage(), None)
|
||||
self.assertEqual(task.start_time, 1000)
|
||||
|
||||
@mock.patch("evennia.scripts.ondemandhandler.OnDemandTask.runtime")
|
||||
def test_stages_autostart(self, mock_runtime):
|
||||
START_TIME = 1000
|
||||
mock_runtime.return_value = START_TIME
|
||||
task = OnDemandTask(
|
||||
"rose",
|
||||
"flower",
|
||||
stages={0: "seedling", 100: "bud", 200: "flower", 300: "wilted", 400: "dead"},
|
||||
)
|
||||
self.assertEqual(task.start_time, 1000)
|
||||
self.assertEqual(
|
||||
task.stages,
|
||||
{
|
||||
0: ("seedling", None),
|
||||
100: ("bud", None),
|
||||
200: ("flower", None),
|
||||
300: ("wilted", None),
|
||||
400: ("dead", None),
|
||||
},
|
||||
)
|
||||
|
||||
# step through the stages
|
||||
self.assertEqual(task.get_dt(), 0)
|
||||
|
||||
self.assertEqual(task.get_stage(), "seedling")
|
||||
|
||||
mock_runtime.return_value = START_TIME + 20
|
||||
self.assertEqual(task.get_stage(), "seedling")
|
||||
|
||||
mock_runtime.return_value = START_TIME + 99.99
|
||||
self.assertEqual(task.get_stage(), "seedling")
|
||||
|
||||
mock_runtime.return_value = START_TIME + 100
|
||||
self.assertEqual(task.get_stage(), "bud")
|
||||
|
||||
mock_runtime.return_value = START_TIME + 250.14
|
||||
self.assertEqual(task.get_stage(), "flower")
|
||||
|
||||
mock_runtime.return_value = START_TIME + 300
|
||||
self.assertEqual(task.get_stage(), "wilted")
|
||||
|
||||
mock_runtime.return_value = START_TIME + 400.0
|
||||
self.assertEqual(task.get_stage(), "dead")
|
||||
|
||||
mock_runtime.return_value = START_TIME + 10000
|
||||
self.assertEqual(task.get_stage(), "dead")
|
||||
|
||||
@mock.patch("evennia.scripts.ondemandhandler.OnDemandTask.runtime")
|
||||
def test_stagefuncs(self, mock_runtime):
|
||||
START_TIME = 0
|
||||
mock_runtime.return_value = START_TIME
|
||||
|
||||
def statefunc(task):
|
||||
task.start_time = 2000
|
||||
|
||||
task = OnDemandTask(
|
||||
"rose",
|
||||
"flower",
|
||||
stages={
|
||||
0: "seedling",
|
||||
100: "bud",
|
||||
200: "flower",
|
||||
300: "wilted",
|
||||
400: ("dead", statefunc),
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(task.get_stage(), "seedling")
|
||||
mock_runtime.return_value = START_TIME + 400
|
||||
self.assertEqual(task.get_stage(), "dead")
|
||||
self.assertEqual(task.start_time, 2000)
|
||||
|
||||
@mock.patch("evennia.scripts.ondemandhandler.OnDemandTask.runtime")
|
||||
def test_stagefunc_loop(self, mock_runtime):
|
||||
START_TIME = 0
|
||||
mock_runtime.return_value = START_TIME
|
||||
|
||||
task = OnDemandTask(
|
||||
"rose",
|
||||
"flower",
|
||||
stages={
|
||||
0: "seedling",
|
||||
50: "bud",
|
||||
150: "flower",
|
||||
300: "wilted",
|
||||
400: "dead",
|
||||
500: ("_loop", OnDemandTask.stagefunc_loop),
|
||||
},
|
||||
)
|
||||
|
||||
self.assertAlmostEqual(task.get_dt(), 0)
|
||||
self.assertEqual(task.get_stage(), "seedling")
|
||||
|
||||
mock_runtime.return_value = START_TIME + 500
|
||||
self.assertEqual(task.get_dt(), 0)
|
||||
self.assertEqual(task.get_stage(), "seedling")
|
||||
|
||||
mock_runtime.return_value = START_TIME + 600
|
||||
self.assertEqual(task.get_dt(), 100)
|
||||
self.assertEqual(task.iterations, 1)
|
||||
self.assertEqual(task.get_stage(), "bud")
|
||||
|
||||
# wait a long time, should loop back indefinitely, counting iterations
|
||||
mock_runtime.return_value = START_TIME + 10250
|
||||
self.assertEqual(task.get_dt(), 250)
|
||||
self.assertEqual(task.iterations, 20)
|
||||
self.assertEqual(task.get_stage(), "flower")
|
||||
|
||||
@mock.patch("evennia.scripts.ondemandhandler.OnDemandTask.runtime")
|
||||
def test_stagefunc_bounce(self, mock_runtime):
|
||||
START_TIME = 0
|
||||
mock_runtime.return_value = START_TIME
|
||||
|
||||
task = OnDemandTask(
|
||||
"reactor",
|
||||
"nuclear",
|
||||
stages={
|
||||
0: ("cold", OnDemandTask.stagefunc_bounce),
|
||||
50: "lukewarm",
|
||||
150: "warm",
|
||||
300: "hot",
|
||||
400: ("HOT!", OnDemandTask.stagefunc_bounce),
|
||||
},
|
||||
)
|
||||
|
||||
self.assertAlmostEqual(task.get_dt(), 0)
|
||||
self.assertEqual(task.get_stage(), "cold")
|
||||
|
||||
mock_runtime.return_value = START_TIME + 400
|
||||
self.assertEqual(task.get_dt(), 0)
|
||||
self.assertEqual(task.get_stage(), "HOT!")
|
||||
self.assertEqual(task.iterations, 1)
|
||||
|
||||
# we should be going back down the sequence
|
||||
mock_runtime.return_value = START_TIME + 450
|
||||
self.assertEqual(task.get_dt(), 50)
|
||||
self.assertEqual(task.get_stage(), "HOT!")
|
||||
|
||||
mock_runtime.return_value = START_TIME + 500
|
||||
self.assertEqual(task.get_dt(), 100)
|
||||
self.assertEqual(task.get_stage(), "hot")
|
||||
|
||||
mock_runtime.return_value = START_TIME + 650
|
||||
self.assertEqual(task.get_dt(), 250)
|
||||
self.assertEqual(task.get_stage(), "warm")
|
||||
|
||||
mock_runtime.return_value = START_TIME + 750
|
||||
self.assertEqual(task.get_dt(), 350)
|
||||
self.assertEqual(task.get_stage(), "lukewarm")
|
||||
|
||||
mock_runtime.return_value = START_TIME + 800
|
||||
self.assertEqual(task.get_dt(), 0)
|
||||
self.assertEqual(task.iterations, 2)
|
||||
self.assertEqual(task.get_stage(), "cold")
|
||||
|
||||
# back up again
|
||||
mock_runtime.return_value = START_TIME + 950
|
||||
self.assertEqual(task.get_dt(), 150)
|
||||
self.assertEqual(task.iterations, 2)
|
||||
self.assertEqual(task.get_stage(), "warm")
|
||||
|
||||
# Waiting a long time
|
||||
mock_runtime.return_value = START_TIME + 10250
|
||||
self.assertEqual(task.get_dt(), 250)
|
||||
self.assertEqual(task.iterations, 25)
|
||||
self.assertEqual(task.get_stage(), "warm")
|
||||
|
||||
|
||||
class TestOnDemandHandler(EvenniaTest):
|
||||
"""
|
||||
Test the OnDemandHandler class.
|
||||
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestOnDemandHandler, self).setUp()
|
||||
self.handler = OnDemandHandler()
|
||||
self.task1 = OnDemandTask(
|
||||
"rose",
|
||||
"flower",
|
||||
stages={0: "seedling", 100: "bud", 200: "flower", 300: "wilted", 400: "dead"},
|
||||
)
|
||||
self.task2 = OnDemandTask(
|
||||
"daffodil",
|
||||
"flower",
|
||||
stages={0: "seedling", 50: "bud", 100: "flower", 150: "wilted", 200: "dead"},
|
||||
)
|
||||
self.task3 = OnDemandTask("test", None)
|
||||
|
||||
def test_add_get(self):
|
||||
self.handler.add("rose", category="flower", stages={0: "seedling"})
|
||||
task = self.handler.get("rose", "flower")
|
||||
self.assertEqual(
|
||||
(task.key, task.category, task.stages), ("rose", "flower", {0: ("seedling", None)})
|
||||
)
|
||||
self.assertEqual(self.handler.get("rose"), None) # no category
|
||||
|
||||
def test_batch_add(self):
|
||||
self.handler.batch_add(self.task1, self.task2, self.task3)
|
||||
task1 = self.handler.get("rose", "flower")
|
||||
task2 = self.handler.get("daffodil", "flower")
|
||||
task3 = self.handler.get("test")
|
||||
self.assertEqual((task1.key, task1.category), ("rose", "flower"))
|
||||
self.assertEqual((task2.key, task2.category), ("daffodil", "flower"))
|
||||
self.assertEqual((task3.key, task3.category), ("test", None))
|
||||
|
||||
def test_remove(self):
|
||||
self.handler.add(self.task1)
|
||||
self.handler.add(self.task2)
|
||||
self.handler.remove(self.task1)
|
||||
self.assertEqual(self.handler.get("rose", "flower"), None)
|
||||
self.assertEqual(self.handler.get("daffodil", "flower"), self.task2)
|
||||
|
||||
def test_batch_remove(self):
|
||||
self.handler.batch_add(self.task1, self.task2, self.task3)
|
||||
self.handler.batch_remove(self.task1, self.task2)
|
||||
self.assertEqual(self.handler.get("rose", "flower"), None)
|
||||
self.assertEqual(self.handler.get("daffodil", "flower"), None)
|
||||
self.assertEqual(self.handler.get("test"), self.task3)
|
||||
|
||||
def test_all(self):
|
||||
self.handler.batch_add(self.task1, self.task2, self.task3)
|
||||
self.assertEqual(
|
||||
self.handler.all(),
|
||||
{
|
||||
(self.task1.key, self.task1.category): self.task1,
|
||||
(self.task2.key, self.task2.category): self.task2,
|
||||
(self.task3.key, self.task3.category): self.task3,
|
||||
},
|
||||
)
|
||||
|
||||
def test_clear(self):
|
||||
self.handler.batch_add(self.task1, self.task2, self.task3)
|
||||
self.handler.clear(all_on_none=False) # only task3 gone (None
|
||||
self.assertEqual(
|
||||
self.handler.all(),
|
||||
{
|
||||
(self.task1.key, self.task1.category): self.task1,
|
||||
(self.task2.key, self.task2.category): self.task2,
|
||||
},
|
||||
)
|
||||
self.handler.clear() # all gone
|
||||
self.assertEqual(self.handler.all(), {})
|
||||
|
||||
def test_save_and_load(self):
|
||||
self.handler.add(self.task1)
|
||||
self.handler.add(self.task2)
|
||||
self.handler.save()
|
||||
self.handler.load()
|
||||
task1 = self.handler.get("rose", "flower")
|
||||
task2 = self.handler.get(self.task2)
|
||||
self.assertEqual((task1.key, task1.category), ("rose", "flower"))
|
||||
self.assertEqual((task2.key, task2.category), ("daffodil", "flower"))
|
||||
|
||||
@mock.patch("evennia.scripts.ondemandhandler.OnDemandTask.runtime")
|
||||
def test_get_dt_and_stage(self, mock_runtime):
|
||||
START_TIME = 0
|
||||
|
||||
mock_runtime.return_value = START_TIME
|
||||
self.handler.batch_add(self.task1, self.task2)
|
||||
|
||||
for task in self.handler.tasks.values():
|
||||
task.start_time = START_TIME
|
||||
|
||||
self.assertEqual(self.handler.get_dt("rose", "flower"), 0)
|
||||
self.assertEqual(self.handler.get_dt("daffodil", "flower"), 0)
|
||||
|
||||
mock_runtime.return_value = START_TIME + 50
|
||||
self.assertEqual(self.handler.get_dt("rose", "flower"), 50)
|
||||
self.assertEqual(self.handler.get_dt("daffodil", "flower"), 50)
|
||||
self.assertEqual(self.handler.get_stage("rose", "flower"), "seedling")
|
||||
self.assertEqual(self.handler.get_stage("daffodil", "flower"), "bud")
|
||||
|
||||
mock_runtime.return_value = START_TIME + 150
|
||||
self.assertEqual(self.handler.get_dt("rose", "flower"), 150)
|
||||
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")
|
||||
|
||||
mock_runtime.return_value = START_TIME + 250
|
||||
self.assertEqual(self.handler.get_dt("rose", "flower"), 250)
|
||||
self.assertEqual(self.handler.get_dt("daffodil", "flower"), 250)
|
||||
self.assertEqual(self.handler.get_stage("rose", "flower"), "flower")
|
||||
self.assertEqual(self.handler.get_stage("daffodil", "flower"), "dead")
|
||||
|
||||
mock_runtime.return_value = START_TIME + 10000
|
||||
self.assertEqual(self.handler.get_dt("rose", "flower"), 10000)
|
||||
self.assertEqual(self.handler.get_dt("daffodil", "flower"), 10000)
|
||||
self.assertEqual(self.handler.get_stage("rose", "flower"), "dead")
|
||||
self.assertEqual(self.handler.get_stage("daffodil", "flower"), "dead")
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ class ServerConfigManager(models.Manager):
|
|||
else:
|
||||
conf = self.filter(db_key=key)
|
||||
if not conf:
|
||||
if callable(default):
|
||||
# allows for `dict` to be a default value
|
||||
return default()
|
||||
return default
|
||||
return conf[0].value
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -552,6 +552,11 @@ class EvenniaServerService(MultiService):
|
|||
|
||||
TICKER_HANDLER.save()
|
||||
|
||||
# on-demand handler state should always be saved.
|
||||
from evennia.scripts.ondemandhandler import ON_DEMAND_HANDLER
|
||||
|
||||
ON_DEMAND_HANDLER.save()
|
||||
|
||||
# always called, also for a reload
|
||||
self.at_server_stop()
|
||||
|
||||
|
|
@ -643,6 +648,11 @@ class EvenniaServerService(MultiService):
|
|||
TASK_HANDLER.load()
|
||||
TASK_HANDLER.create_delays()
|
||||
|
||||
# start the On-demand handler
|
||||
from evennia.scripts.ondemandhandler import ON_DEMAND_HANDLER
|
||||
|
||||
ON_DEMAND_HANDLER.load()
|
||||
|
||||
# create/update channels
|
||||
self.create_default_channels()
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from os.path import join as osjoin
|
|||
from string import punctuation
|
||||
from unicodedata import east_asian_width
|
||||
|
||||
import evennia
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
|
@ -35,14 +36,12 @@ from django.core.validators import validate_email as django_validate_email
|
|||
from django.utils import timezone
|
||||
from django.utils.html import strip_tags
|
||||
from django.utils.translation import gettext as _
|
||||
from evennia.utils import logger
|
||||
from simpleeval import simple_eval
|
||||
from twisted.internet import reactor, threads
|
||||
from twisted.internet.defer import returnValue # noqa - used as import target
|
||||
from twisted.internet.task import deferLater
|
||||
|
||||
import evennia
|
||||
from evennia.utils import logger
|
||||
|
||||
_MULTIMATCH_TEMPLATE = settings.SEARCH_MULTIMATCH_TEMPLATE
|
||||
_EVENNIA_DIR = settings.EVENNIA_DIR
|
||||
_GAME_DIR = settings.GAME_DIR
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue