diff --git a/evennia/contrib/events/custom.py b/evennia/contrib/events/custom.py index 53071f36c6..a4cef9a6ef 100644 --- a/evennia/contrib/events/custom.py +++ b/evennia/contrib/events/custom.py @@ -80,44 +80,6 @@ def invalidate_event_type(typeclass, event_name): typeclass_name = typeclass.__module__ + "." + typeclass.__name__ event_types.append((typeclass_name, event_name, None, "", None, None)) -def patch_hook(typeclass, method_name): - """ - Decorator to softly patch a hook in a typeclass. - - This decorator should not be used, unless for good reasons, outside - of this contrib. The advantage of using decorated soft patchs is - in allowing users to customize typeclasses without changing the - inheritance tree for a couple of methods. - - """ - hook = getattr(typeclass, method_name, None) - def wrapper(method): - """Wrapper around the hook.""" - def overridden_hook(*args, **kwargs): - """Function to call the new hook.""" - # Enforce the old hook as a keyword argument - kwargs["hook"] = hook - ret = method(*args, **kwargs) - return ret - hooks.append((typeclass, method_name, overridden_hook)) - return overridden_hook - return wrapper - -def patch_hooks(): - """ - Patch all the configured hooks. - - This function should be called only once when the event system - has loaded, is set and has defined its patched typeclasses. - It will be called internally by the event system, you shouldn't - call this function in your game. - - """ - while hooks: - typeclass, method_name, new_hook = hooks[0] - setattr(typeclass, method_name, new_hook) - del hooks[0] - def connect_event_types(): """ Connect the event types when the script runs. @@ -243,7 +205,7 @@ def create_time_event(obj, event_name, number, parameters): def keyword_event(events, parameters): """ - Custom call for events with keywords (like say, or push, or pull, or turn...). + Custom call for events with keywords (like push, or pull, or turn...). This function should be imported and added as a custom_call parameter to add the event type when the event supports keywords @@ -267,3 +229,37 @@ def keyword_event(events, parameters): to_call.append(event) return to_call + +def phrase_event(events, parameters): + """ + Custom call for events with keywords in sentences (like say or whisper). + + This function should be imported and added as a custom_call + parameter to add the event type when the event supports keywords + in phrase as parameters. Keywords in parameters are one or more + words separated by a comma. For instance, a 'say yes, okay' event + can be set to trigger when the player says something containing + either "yes" or "okay" (maybe 'say I don't like it, but okay'). + + Args: + events (list of dict): the list of events to be called. + parameters (str): the actual parameters entered to trigger the event. + + Returns: + A list containing the event dictionaries to be called. + + """ + phrase = parameters.strip().lower() + # Remove punctuation marks + punctuations = ',.";?!' + for p in punctuations: + phrase = phrase.replace(p, " ") + words = phrase.split() + words = [w.strip("' ") for w in words if w.strip("' ")] + to_call = [] + for event in events: + keys = event["parameters"] + if not keys or any(key.strip().lower() in words for key in keys.split(",")): + to_call.append(event) + + return to_call diff --git a/evennia/contrib/events/scripts.py b/evennia/contrib/events/scripts.py index 839381949a..d46909c33e 100644 --- a/evennia/contrib/events/scripts.py +++ b/evennia/contrib/events/scripts.py @@ -14,8 +14,7 @@ from evennia import logger from evennia.utils.create import create_channel from evennia.utils.dbserialize import dbserialize from evennia.utils.utils import all_from_module, delay -from evennia.contrib.events.custom import ( - connect_event_types, get_next_wait, patch_hooks) +from evennia.contrib.events.custom import connect_event_types, get_next_wait from evennia.contrib.events.exceptions import InterruptEvent from evennia.contrib.events.handler import EventsHandler as Handler from evennia.contrib.events import typeclasses @@ -36,6 +35,7 @@ class EventHandler(DefaultScript): """ def at_script_creation(self): + """Hook called when the script is created.""" self.key = "event_handler" self.desc = "Global event handler" self.persistent = True @@ -50,10 +50,21 @@ class EventHandler(DefaultScript): self.db.tasks = {} def at_start(self): - """Set up the event system.""" + """Set up the event system when starting. + + Note that this hook is called every time the server restarts + (including when it's reloaded). This hook performs the following + tasks: + + - Refresh and re-connect event types. + - Generate locals (individual events' namespace). + - Load event helpers, including user-defined ones. + - Re-schedule tasks that aren't set to fire anymore. + - Effectively connect the handler to the main script. + + """ self.ndb.event_types = {} connect_event_types() - patch_hooks() # Generate locals self.ndb.current_locals = {} diff --git a/evennia/contrib/events/tests.py b/evennia/contrib/events/tests.py index 09c5b5c942..f5b1a40f5a 100644 --- a/evennia/contrib/events/tests.py +++ b/evennia/contrib/events/tests.py @@ -30,6 +30,13 @@ class TestEventHandler(EvenniaTest): self.handler = create_script( "evennia.contrib.events.scripts.EventHandler") + # Alter typeclasses + self.char1.swap_typeclass("evennia.contrib.events.typeclasses.EventCharacter") + self.char2.swap_typeclass("evennia.contrib.events.typeclasses.EventCharacter") + self.room1.swap_typeclass("evennia.contrib.events.typeclasses.EventRoom") + self.room2.swap_typeclass("evennia.contrib.events.typeclasses.EventRoom") + self.exit.swap_typeclass("evennia.contrib.events.typeclasses.EventExit") + def tearDown(self): """Stop the event handler.""" self.handler.stop() @@ -225,28 +232,28 @@ class TestEventHandler(EvenniaTest): self.assertIsNotNone(self.char1.events) # Add an event - event = self.room1.events.add("say", "pass", author=self.char1, + event = self.room1.events.add("dummy", "pass", author=self.char1, valid=True) self.assertEqual(event.obj, self.room1) - self.assertEqual(event.name, "say") + self.assertEqual(event.name, "dummy") self.assertEqual(event.code, "pass") self.assertEqual(event.author, self.char1) self.assertEqual(event.valid, True) self.assertIn([event], self.room1.events.all().values()) # Edit this very event - new = self.room1.events.edit("say", 0, "character.db.say = True", + new = self.room1.events.edit("dummy", 0, "character.db.say = True", author=self.char1, valid=True) self.assertIn([new], self.room1.events.all().values()) self.assertNotIn([event], self.room1.events.all().values()) # Try to call this event - self.assertTrue(self.room1.events.call("say", + self.assertTrue(self.room1.events.call("dummy", locals={"character": self.char2})) self.assertTrue(self.char2.db.say) # Delete the event - self.room1.events.remove("say", 0) + self.room1.events.remove("dummy", 0) self.assertEqual(self.room1.events.all(), {}) @@ -260,6 +267,13 @@ class TestCmdEvent(CommandTest): self.handler = create_script( "evennia.contrib.events.scripts.EventHandler") + # Alter typeclasses + self.char1.swap_typeclass("evennia.contrib.events.typeclasses.EventCharacter") + self.char2.swap_typeclass("evennia.contrib.events.typeclasses.EventCharacter") + self.room1.swap_typeclass("evennia.contrib.events.typeclasses.EventRoom") + self.room2.swap_typeclass("evennia.contrib.events.typeclasses.EventRoom") + self.exit.swap_typeclass("evennia.contrib.events.typeclasses.EventExit") + def tearDown(self): """Stop the event handler.""" self.handler.stop() @@ -412,6 +426,13 @@ class TestDefaultEvents(CommandTest): self.handler = create_script( "evennia.contrib.events.scripts.EventHandler") + # Alter typeclasses + self.char1.swap_typeclass("evennia.contrib.events.typeclasses.EventCharacter") + self.char2.swap_typeclass("evennia.contrib.events.typeclasses.EventCharacter") + self.room1.swap_typeclass("evennia.contrib.events.typeclasses.EventRoom") + self.room2.swap_typeclass("evennia.contrib.events.typeclasses.EventRoom") + self.exit.swap_typeclass("evennia.contrib.events.typeclasses.EventExit") + def tearDown(self): """Stop the event handler.""" self.handler.stop() @@ -428,6 +449,9 @@ class TestDefaultEvents(CommandTest): character.msg("You cannot leave.") deny() """.strip("\n")) + # Enforce self.exit.destination since swapping typeclass lose it + self.exit.destination = self.room2 + # Try the can_traverse event self.handler.add_event(self.exit, "can_traverse", code, author=self.char1, valid=True) diff --git a/evennia/contrib/events/typeclasses.py b/evennia/contrib/events/typeclasses.py index b9ca7961b9..f6b3724197 100644 --- a/evennia/contrib/events/typeclasses.py +++ b/evennia/contrib/events/typeclasses.py @@ -1,33 +1,23 @@ """ -Patched typeclasses for Evennia. +Typeclasses for the event system. -These typeclasses are not inherited from DefaultObject and other -Evennia default types. They softly "patch" some of these object hooks -however. While this adds a new layer in this module, it's (normally) -more simple to use from game designers, since it doesn't require a -new inheritance. These replaced hooks are only active if the event -system is active. You shouldn't need to change this module, just -override the hooks as you usually do in your custom typeclasses. -Calling super() would call the Default hooks (which would call the -event hook without further ado). +To use thm, one should inherit from these classes (EventObject, +EventRoom, EventCharacter and EventExit). """ from evennia import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom from evennia import ScriptDB -from evennia.utils.utils import inherits_from, lazy_property +from evennia.utils.utils import delay, inherits_from, lazy_property from evennia.contrib.events.custom import ( - create_event_type, invalidate_event_type, patch_hook, create_time_event) + create_event_type, invalidate_event_type, create_time_event, phrase_event) from evennia.contrib.events.handler import EventsHandler -class EventCharacter: +class EventCharacter(DefaultCharacter): - """Patched typeclass for DefaultCharcter.""" + """Typeclass to represent a character and call event types.""" - @staticmethod - @patch_hook(DefaultCharacter, "announce_move_from") - def announce_move_from(character, destination, msg=None, mapping=None, - hook=None): + def announce_move_from(self, destination, msg=None, mapping=None): """ Called if the move is to be announced. This is called while we are still standing in the old @@ -47,21 +37,21 @@ class EventCharacter: destination: the location of the object after moving. """ - if not character.location: + if not self.location: return string = msg or "{object} is leaving {origin}, heading for {destination}." # Get the exit from location to destination - location = character.location + location = self.location exits = [o for o in location.contents if o.location is location and o.destination is destination] mapping = mapping or {} mapping.update({ - "character": character, + "character": self, }) if exits: - exits[0].events.call("msg_leave", character, exits[0], + exits[0].events.call("msg_leave", self, exits[0], location, destination, string, mapping) string = exits[0].events.get_variable("message") mapping = exits[0].events.get_variable("mapping") @@ -71,13 +61,9 @@ class EventCharacter: if not string: return - if hook: - hook(character, destination, msg=string, mapping=mapping) + super(EventCharacter, self).announce_move_from(destination, msg=string, mapping=mapping) - @staticmethod - @patch_hook(DefaultCharacter, "announce_move_to") - def announce_move_to(character, source_location, msg=None, mapping=None, - hook=None): + def announce_move_to(self, source_location, msg=None, mapping=None): """ Called after the move if the move was not quiet. At this point we are standing in the new location. @@ -97,11 +83,11 @@ class EventCharacter: """ - if not source_location and character.location.has_player: + if not source_location and self.location.has_player: # This was created from nowhere and added to a player's # inventory; it's probably the result of a create command. string = "You now have %s in your possession." % self.get_display_name(self.location) - character.location.msg(string) + self.location.msg(string) return if source_location: @@ -110,17 +96,17 @@ class EventCharacter: string = "{character} arrives to {destination}." origin = source_location - destination = character.location + destination = self.location exits = [] mapping = mapping or {} mapping.update({ - "character": character, + "character": self, }) if origin: exits = [o for o in destination.contents if o.location is destination and o.destination is origin] if exits: - exits[0].events.call("msg_arrive", character, exits[0], + exits[0].events.call("msg_arrive", self, exits[0], origin, destination, string, mapping) string = exits[0].events.get_variable("message") mapping = exits[0].events.get_variable("mapping") @@ -130,12 +116,9 @@ class EventCharacter: if not string: return - if hook: - hook(character, source_location, msg=string, mapping=mapping) + super(EventCharacter, self).announce_move_to(source_location, msg=string, mapping=mapping) - @staticmethod - @patch_hook(DefaultCharacter, "at_before_move") - def at_before_move(character, destination, hook=None): + def at_before_move(self, destination): """ Called just before starting to move this object to destination. @@ -151,18 +134,18 @@ class EventCharacter: before it is even started. """ - origin = character.location + origin = self.location Room = DefaultRoom if isinstance(origin, Room) and isinstance(destination, Room): - can = character.events.call("can_move", character, + can = self.events.call("can_move", self, origin, destination) if can: - can = origin.events.call("can_move", character, origin) + can = origin.events.call("can_move", self, origin) if can: # Call other character's 'can_part' event for present in [o for o in origin.contents if isinstance( - o, DefaultCharacter) and o is not character]: - can = present.events.call("can_part", present, character) + o, DefaultCharacter) and o is not self]: + can = present.events.call("can_part", present, self) if not can: break @@ -173,9 +156,7 @@ class EventCharacter: return True - @staticmethod - @patch_hook(DefaultCharacter, "at_after_move") - def at_after_move(character, source_location, hook=None): + def at_after_move(self, source_location): """ Called after move has completed, regardless of quiet mode or not. Allows changes to the object due to the location it is @@ -185,39 +166,34 @@ class EventCharacter: source_location (Object): Wwhere we came from. This may be `None`. """ - if hook: - hook(character, source_location) + super(EventCharacter, self).at_after_move(source_location) origin = source_location - destination = character.location + destination = self.location Room = DefaultRoom if isinstance(origin, Room) and isinstance(destination, Room): - character.events.call("move", character, origin, destination) - destination.events.call("move", character, origin, destination) + self.events.call("move", self, origin, destination) + destination.events.call("move", self, origin, destination) # Call the 'greet' event of characters in the location for present in [o for o in destination.contents if isinstance( - o, DefaultCharacter)]: - present.events.call("greet", present, character) + o, DefaultCharacter) and o is not self]: + present.events.call("greet", present, self) - @staticmethod - @patch_hook(DefaultCharacter, "at_object_delete") - def at_object_delete(character, hook=None): + def at_object_delete(self): """ Called just before the database object is permanently delete()d from the database. If this method returns False, deletion is aborted. """ - if not character.events.call("can_delete", character): + if not self.events.call("can_delete", self): return False - character.events.call("delete", character) + self.events.call("delete", self) return True - @staticmethod - @patch_hook(DefaultCharacter, "at_post_puppet") - def at_post_puppet(character, hook=None): + def at_post_puppet(self): """ Called just after puppeting has been completed and all Player<->Object links have been established. @@ -229,19 +205,16 @@ class EventCharacter: puppeting this Object. """ - if hook: - hook(character) + super(EventCharacter, self).at_post_puppet() - character.events.call("puppeted", character) + self.events.call("puppeted", self) # Call the room's puppeted_in event - location = character.location + location = self.location if location and isinstance(location, DefaultRoom): - location.events.call("puppeted_in", character, location) + location.events.call("puppeted_in", self, location) - @staticmethod - @patch_hook(DefaultCharacter, "at_pre_unpuppet") - def at_pre_unpuppet(character, hook=None): + def at_pre_unpuppet(self): """ Called just before beginning to un-connect a puppeting from this Player. @@ -253,24 +226,21 @@ class EventCharacter: puppeting this Object. """ - character.events.call("unpuppeted", character) - - if hook: - hook(character) + self.events.call("unpuppeted", self) # Call the room's unpuppeted_in event - location = character.location + location = self.location if location and isinstance(location, DefaultRoom): - location.events.call("unpuppeted_in", character, location) + location.events.call("unpuppeted_in", self, location) + + super(EventCharacter, self).at_pre_unpuppet() -class EventExit(object): +class EventExit(DefaultExit): - """Patched exit to patch some hooks of DefaultExit.""" + """Modified exit including management of events.""" - @staticmethod - @patch_hook(DefaultExit, "at_traverse") - def at_traverse(exit, traversing_object, target_location, hook=None): + def at_traverse(self, traversing_object, target_location): """ This hook is responsible for handling the actual traversal, normally by calling @@ -287,51 +257,91 @@ class EventExit(object): """ is_character = inherits_from(traversing_object, DefaultCharacter) if is_character: - allow = exit.events.call("can_traverse", traversing_object, - exit, exit.location) + allow = self.events.call("can_traverse", traversing_object, + self, self.location) if not allow: return - if hook: - hook(exit, traversing_object, target_location) + super(EventExit, self).at_traverse(traversing_object, target_location) # After traversing if is_character: - exit.events.call("traverse", traversing_object, - exit, exit.location, exit.destination) + self.events.call("traverse", traversing_object, + self, self.location, self.destination) -class EventRoom: +class EventRoom(DefaultRoom): - """Soft-patching of room's default hooks.""" + """Default room with management of events.""" - @staticmethod - @patch_hook(DefaultRoom, "at_object_delete") - def at_object_delete(room, hook=None): + def at_object_delete(self): """ Called just before the database object is permanently delete()d from the database. If this method returns False, deletion is aborted. """ - if not room.events.call("can_delete", room): + if not self.events.call("can_delete", self): return False - room.events.call("delete", room) + self.events.call("delete", self) return True + def at_say(self, speaker, message): + """ + Called on this object if an object inside this object speaks. + The string returned from this method is the final form of the + speech. -class EventObject(object): + Args: + speaker (Object): The object speaking. + message (str): The words spoken. - """Patched default object.""" + Notes: + You should not need to add things like 'you say: ' or + similar here, that should be handled by the say command before + this. + + """ + allow = self.events.call("can_say", speaker, self, message, + parameters=message) + if not allow: + return + + message = self.events.get_variable("message") + + # Call the event "can_say" of other characters in the location + for present in [o for o in self.contents if isinstance( + o, DefaultCharacter) and o is not speaker]: + allow = present.events.call("can_say", speaker, present, + message, parameters=message) + if not allow: + return + + message = present.events.get_variable("message") + + # We force the next event to be called after the message + # This will have to change when the Evennia API adds new hooks + delay(0, self.events.call, "say", speaker, self, message, + parameters=message) + for present in [o for o in self.contents if isinstance( + o, DefaultCharacter) and o is not speaker]: + delay(0, present.events.call, "say", speaker, present, message, + parameters=message) + + return message + + +class EventObject(DefaultObject): + + """Default object with management of events.""" @lazy_property def events(self): """Return the EventsHandler.""" return EventsHandler(self) - @staticmethod - @patch_hook(DefaultObject, "at_get") - def at_get(obj, getter, hook=None): + + def at_get(self, getter): """ Called by the default `get` command when this object has been picked up. @@ -344,14 +354,10 @@ class EventObject(object): permissions for that. """ - if hook: - hook(obj, getter) + super(EventObject, self).at_get(getter) + self.events.call("get", getter, self) - obj.events.call("get", getter, obj) - - @staticmethod - @patch_hook(DefaultObject, "at_drop") - def at_drop(obj, dropper, hook=None): + def at_drop(self, dropper): """ Called by the default `drop` command when this object has been dropped. @@ -364,10 +370,8 @@ class EventObject(object): permissions from that. """ - if hook: - hook(obj, dropper) - - obj.events.call("drop", dropper, obj) + super(EventObject, self).at_drop(dropper) + self.events.call("drop", dropper, self) ## Default events # Character events @@ -406,6 +410,19 @@ create_event_type(DefaultCharacter, "can_part", ["character", "departing"], """ departing: the character who wants to leave this room. character: the character connected to this event. """) +create_event_type(DefaultCharacter, "can_say", ["speaker", "character", "message"], """ + Before another character can say something in the same location. + This event is called before another character says something in the + character's location. The "something" in question can be modified, + or the action can be prevented by using 'deny()'. To change the + content of what the character says, simply change the variable + 'message' to another string of characters. + + Variables you can use in this event: + speaker: the character who is using the say command. + character: the character connected to this event. + message: the text spoken by the character. + """, custom_call=phrase_event) create_event_type(DefaultCharacter, "delete", ["character"], """ Before deleting the character. This event is called just before deleting this character. It shouldn't @@ -450,6 +467,27 @@ create_event_type(DefaultCharacter, "puppeted", ["character"], """ Variables you can use in this event: character: the character connected to this event. """) +create_event_type(DefaultCharacter, "say", ["speaker", "character", "message"], """ + After another character has said something in the character's room. + This event is called right after another character has said + something in the same location.. The action cannot be prevented + at this moment. Instead, this event is ideal to create keywords + that would trigger a character (like a NPC) in doing something + if a specific phrase is spoken in the same location. + To use this event, you have to specify a list of keywords as + parameters that should be present, as separate words, in the + spoken phrase. For instance, you can set an event tthat would + fire if the phrase spoken by the character contains "menu" or + "dinner" or "lunch": + @event/add ... = say menu, dinner, lunch + Then if one of the words is present in what the character says, + this event will fire. + + Variables you can use in this event: + speaker: the character speaking in this room. + character: the character connected to this event. + message: the text having been spoken by the character. + """, custom_call=phrase_event) create_event_type(DefaultCharacter, "time", ["character"], """ A repeated event to be called regularly. This event is scheduled to repeat at different times, specified @@ -631,6 +669,19 @@ create_event_type(DefaultRoom, "can_move", ["character", "room"], """ character: the character who wants to move in this room. room: the room connected to this event. """) +create_event_type(DefaultRoom, "can_say", ["character", "room", "message"], """ + Before a character can say something in this room. + This event is called before a character says something in this + room. The "something" in question can be modified, or the action + can be prevented by using 'deny()'. To change the content of what + the character says, simply change the variable 'message' to another + string of characters. + + Variables you can use in this event: + character: the character who is using the say command. + room: the room connected to this event. + message: the text spoken by the character. + """, custom_call=phrase_event) create_event_type(DefaultRoom, "delete", ["room"], """ Before deleting the room. This event is called just before deleting this room. It shouldn't @@ -665,6 +716,25 @@ create_event_type(DefaultRoom, "puppeted_in", ["character", "room"], """ character: the character who have just been puppeted in this room. room: the room connected to this event. """) +create_event_type(DefaultRoom, "say", ["character", "room", "message"], """ + After the character has said something in the room. + This event is called right after a character has said something + in this room. The action cannot be prevented at this moment. + Instead, this event is ideal to create actions that will respond + to something being said aloud. To use this event, you have to + specify a list of keywords as parameters that should be present, + as separate words, in the spoken phrase. For instance, you can + set an event tthat would fire if the phrase spoken by the character + contains "menu" or "dinner" or "lunch": + @event/add ... = say menu, dinner, lunch + Then if one of the words is present in what the character says, + this event will fire. + + Variables you can use in this event: + character: the character having spoken in this room. + room: the room connected to this event. + message: the text having been spoken by the character. + """, custom_call=phrase_event) create_event_type(DefaultRoom, "time", ["room"], """ A repeated event to be called regularly. This event is scheduled to repeat at different times, specified