diff --git a/evennia/__init__.py b/evennia/__init__.py index bcf39069b8..ab130a5874 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -94,6 +94,7 @@ EvEditor = None EvMore = None ANSIString = None signals = None +FuncParser = None # Handlers SESSION_HANDLER = None @@ -157,7 +158,7 @@ def _init(portal_mode=False): global TASK_HANDLER global GLOBAL_SCRIPTS, OPTION_CLASSES global EvMenu, EvTable, EvForm, EvMore, EvEditor - global ANSIString + global ANSIString, FuncParser global AttributeProperty, TagProperty, TagCategoryProperty # Parent typeclasses @@ -203,6 +204,7 @@ def _init(portal_mode=False): from .utils.evmenu import EvMenu from .utils.evmore import EvMore from .utils.evtable import EvTable + from .utils.funcparser import FuncParser # search functions from .utils.search import ( diff --git a/evennia/contrib/grid/extended_room/README.md b/evennia/contrib/grid/extended_room/README.md index 5fd6217f36..f40f020378 100644 --- a/evennia/contrib/grid/extended_room/README.md +++ b/evennia/contrib/grid/extended_room/README.md @@ -1,15 +1,19 @@ # Extended Room -Contribution - Griatch 2012, vincent-lg 2019 +Contribution - Griatch 2012, vincent-lg 2019, Griatch 2023 -This extends the normal `Room` typeclass to allow its description to change -with time-of-day and/or season. It also adds 'details' for the player to look at -in the room (without having to create a new in-game object for each). The room is -supported by new `look` and `desc` commands. +This extends the normal `Room` typeclass to allow its description to change with +time-of-day and/or season as well as any other state (like flooded or dark). +Embedding `$state(burning, This place is on fire!)` in the description will +allow for changing the description based on room state. The room also supports +`details` for the player to look at in the room (without having to create a new +in-game object for each), as well as support for random echoes. The room +comes with a set of alternate commands for `look` and `@desc`, as well as new +commands `detail`, `roomstate` and `time`. -## Installation/testing: +## Installation -Adding the `ExtendedRoomCmdset` to the default character cmdset will add all +Add the `ExtendedRoomCmdset` to the default character cmdset will add all new commands for use. In more detail, in `mygame/commands/default_cmdsets.py`: @@ -30,52 +34,157 @@ class CharacterCmdset(default_cmds.CharacterCmdSet): Then reload to make the new commands available. Note that they only work on rooms with the typeclass `ExtendedRoom`. Create new rooms with the right typeclass or use the `typeclass` command to swap existing rooms. Note that since -this contrib overrides the `look` command, you will need to add the +this contrib overrides the `look` and `@desc` commands, you will need to add the `extended_room.ExtendedRoomCmdSet` to the default character cmdset *after* -super().at_cmdset_creation(), or it will be overridden by the default look. +`super().at_cmdset_creation()`, or they will be overridden by the default look. + +To dig a new extended room: + + dig myroom:evennia.contrib.grid.extended_room.ExtendedRoom = north,south + +To make all new rooms ExtendedRooms without having to specify it, make your +`Room` typeclass inherit from the `ExtendedRoom` and then reload: + +```python +# in mygame/typeclasses/rooms.py + +from evennia.contrib.grid.extended_room import ExtendedRoom + +# ... + +class Room(ObjectParent, ExtendedRoom): + # ... + +``` ## Features -### Time-changing description slots +### State-dependent description slots -This allows to change the full description text the room shows -depending on larger time variations. Four seasons (spring, summer, -autumn and winter) are used by default. The season is calculated -on-demand (no Script or timer needed) and updates the full text block. +By default, the normal `room.db.desc` description is used. You can however +add new state-ful descriptions with `room.add_desc(description, +room_state=roomstate)` or with the in-game command -There is also a general description which is used as fallback if -one or more of the seasonal descriptions are not set when their -time comes. +``` +@desc/roomstate [] +``` -An updated `desc` command allows for setting seasonal descriptions. +For example -The room uses the `evennia.utils.gametime.GameTime` global script. This is -started by default, but if you have deactivated it, you need to -supply your own time keeping mechanism. +``` +@desc/dark This room is pitch black.`. -### In-description changing tags +``` -Within each seasonal (or general) description text, you can also embed -time-of-day dependent sections. Text inside such a tag will only show -during that particular time of day. The tags looks like ` ... -`. By default there are four timeslots per day - morning, -afternoon, evening and night. + +These will be stored in Attributes `desc_`. To set the default, +fallback description, just use `@desc `. +To activate a state on the room, use `room.add/remove_state(*roomstate)` or the in-game +command +``` +roomstate (use it again to toggle the state off) +``` +For example +``` +roomstate dark +``` +There is one in-built, time-based state `season`. By default these are 'spring', +'summer', 'autumn' and 'winter'. The `room.get_season()` method returns the +current season based on the in-game time. By default they change with a 12-month +in-game time schedule. You can control them with +``` +ExtendedRoom.months_per_year # default 12 +ExtendedRoom.seasons_per year # a dict of {"season": (start, end), ...} where + # start/end are given in fractions of the whole year +``` +To set a seasonal description, just set it as normal, with `room.add_desc` or +in-game with + +``` +@desc/winter This room is filled with snow. +@desc/autumn Red and yellow leaves cover the ground. +``` + +Normally the season changes with the in-game time, you can also 'force' a given +season by setting its state +``` +roomstate winter +``` +If you set the season manually like this, it won't change automatically again +until you unset it. + +You can get the stateful description from the room with `room.get_stateful_desc()`. + +### Changing parts of description based on state + +All descriptions can have embedded `$state(roomstate, description)` +[FuncParser tags](FuncParser) embedded in them. Here is an example: + +```py +room.add_desc("This a nice beach. " + "$state(empty, It is completely empty)" + "$state(full, It is full of people).", room_state="summer") +``` + +This is a summer-description with special embedded strings. If you set the room +with + + > room.add_room_state("summer", "empty") + > room.get_stateful_desc() + + This is a nice beach. It is completely empty + + > room.remove_room_state("empty") + > room.add_room_state("full") + > room.get_stateful_desc() + + This is a nice beach. It is full of people. + +There are four time-of-day states that are meant to be used with these tags. The +room tracks and changes these automatically. By default they are 'morning', +'afternoon', 'evening' and 'night'. You can get the current time-slot with +`room.get_time_of_day`. You can control them with + +``` +ExtendedRoom.hours_per_day # default 24 +ExtendedRoom.times_of_day # dict of {season: (start, end), ...} where + # the start/end are given as fractions of the day +``` + +You use these inside descriptions as normal: + + "A glade. $(morning, The morning sun shines down through the branches)." ### Details -The Extended Room can be "detailed" with special keywords. This makes -use of a special `Look` command. Details are "virtual" targets to look -at, without there having to be a database object created for it. The -Details are simply stored in a dictionary on the room and if the look -command cannot find an object match for a `look ` command it -will also look through the available details at the current location -if applicable. The `detail` command is used to change details. +_Details_ are "virtual" targets to look at in a room, without having to create a +new database instance for every thing. It's good to add more information to a +location. The details are stored as strings in a dictionary. + + detail window = There is a window leading out. + detail rock = The rock has a text written on it: 'Do not dare lift me'. + +When you are in the room you can then do `look window` or `look rock` and get +the matching detail-description. This requires the new custom `look` command. + +### Random echoes + +The `ExtendedRoom` supports random echoes. Just set them as an Attribute list +`room_messages`: + +``` +room.room_message_rate = 120 # in seconds. 0 to disable +room.db.room_messages = ["A car passes by.", "You hear the sound of car horns."] +room.start_repeat_broadcast_messages() # also a server reload works +``` + +These will start randomly echoing to the room every 120s. + ### Extra commands -- `CmdExtendedRoomLook` - look command supporting room details -- `CmdExtendedRoomDesc` - desc command allowing to add seasonal descs, -- `CmdExtendedRoomDetail` - command allowing to manipulate details in this room - as well as listing them -- `CmdExtendedRoomGameTime` - A simple `time` command, displaying the current - time and season. +- `CmdExtendedRoomLook` (`look`) - look command supporting room details +- `CmdExtendedRoomDesc` (`@desc`) - desc command allowing to add stateful descs, +- `CmdExtendeRoomState` (`roomstate`) - toggle room states +- `CmdExtendedRoomDetail` (`detail`) - list and manipulate room details +- `CmdExtendedRoomGameTime` (`time`) - Shows the current time and season in the room. diff --git a/evennia/contrib/grid/extended_room/__init__.py b/evennia/contrib/grid/extended_room/__init__.py index 3714e47f1b..53a673c793 100644 --- a/evennia/contrib/grid/extended_room/__init__.py +++ b/evennia/contrib/grid/extended_room/__init__.py @@ -1,5 +1,5 @@ """ -Extended Room - Griatch 2012, vincent-lg 2019 +Extended Room - Griatch 2012, vincent-lg 2019, Griatch 2023 """ @@ -7,5 +7,6 @@ from .extended_room import CmdExtendedRoomDesc # noqa from .extended_room import CmdExtendedRoomDetail # noqa from .extended_room import CmdExtendedRoomGameTime # noqa from .extended_room import CmdExtendedRoomLook # noqa +from .extended_room import CmdExtendedRoomState # noqa from .extended_room import ExtendedRoom # noqa from .extended_room import ExtendedRoomCmdSet # noqa diff --git a/evennia/contrib/grid/extended_room/extended_room.py b/evennia/contrib/grid/extended_room/extended_room.py index 33ab3200d3..499c95ae40 100644 --- a/evennia/contrib/grid/extended_room/extended_room.py +++ b/evennia/contrib/grid/extended_room/extended_room.py @@ -1,61 +1,18 @@ """ Extended Room -Evennia Contribution - Griatch 2012, vincent-lg 2019 +Evennia Contribution - Griatch 2012, vincent-lg 2019, Griatch 2023 -This is an extended Room typeclass for Evennia. It is supported -by an extended `Look` command and an extended `desc` command, also -in this module. +This is an extended Room typeclass for Evennia, supporting descriptions that vary +by season, time-of-day or arbitrary states (like burning). It has details, embedded +state tags, support for repeating random messages as well as a few extra commands. - -Features: - -1) Time-changing description slots - -This allows to change the full description text the room shows -depending on larger time variations. Four seasons (spring, summer, -autumn and winter) are used by default. The season is calculated -on-demand (no Script or timer needed) and updates the full text block. - -There is also a general description which is used as fallback if -one or more of the seasonal descriptions are not set when their -time comes. - -An updated `desc` command allows for setting seasonal descriptions. - -The room uses the `evennia.utils.gametime.GameTime` global script. This is -started by default, but if you have deactivated it, you need to -supply your own time keeping mechanism. - - -2) In-description changing tags - -Within each seasonal (or general) description text, you can also embed -time-of-day dependent sections. Text inside such a tag will only show -during that particular time of day. The tags looks like ` ... -`. By default there are four timeslots per day - morning, -afternoon, evening and night. - - -3) Details - -The Extended Room can be "detailed" with special keywords. This makes -use of a special `Look` command. Details are "virtual" targets to look -at, without there having to be a database object created for it. The -Details are simply stored in a dictionary on the room and if the look -command cannot find an object match for a `look ` command it -will also look through the available details at the current location -if applicable. The `detail` command is used to change details. - - -4) Extra commands - - CmdExtendedRoomLook - look command supporting room details - CmdExtendedRoomDesc - desc command allowing to add seasonal descs, - CmdExtendedRoomDetail - command allowing to manipulate details in this room - as well as listing them - CmdExtendedRoomGameTime - A simple `time` command, displaying the current - time and season. +- The room description can be set to change depending on the season or time of day. +- Parts of the room description can be set to change depending on arbitrary states (like burning). +- Details can be added to the room, which can be looked at like objects. +- Alternative text sections can be added to the room description, which will only show if + the room is in a given state. +- Random messages can be set to repeat at a given rate. Installation/testing: @@ -83,154 +40,444 @@ typeclass or use the `typeclass` command to swap existing rooms. """ - import datetime +import random import re +from collections import deque from django.conf import settings -from evennia import CmdSet, DefaultRoom, default_cmds, gametime, utils +from django.db.models import Q +from evennia import ( + CmdSet, + DefaultRoom, + EvEditor, + FuncParser, + InterruptCommand, + default_cmds, + gametime, + utils, +) +from evennia.typeclasses.attributes import AttributeProperty +from evennia.utils.utils import list_to_string, repeat # error return function, needed by Extended Look command _AT_SEARCH_RESULT = utils.variable_from_module(*settings.SEARCH_AT_RESULT.rsplit(".", 1)) -# regexes for in-desc replacements -RE_MORNING = re.compile(r"(.*?)", re.IGNORECASE) -RE_AFTERNOON = re.compile(r"(.*?)", re.IGNORECASE) -RE_EVENING = re.compile(r"(.*?)", re.IGNORECASE) -RE_NIGHT = re.compile(r"(.*?)", re.IGNORECASE) -# this map is just a faster way to select the right regexes (the first -# regex in each tuple will be parsed, the following will always be weeded out) -REGEXMAP = { - "morning": (RE_MORNING, RE_AFTERNOON, RE_EVENING, RE_NIGHT), - "afternoon": (RE_AFTERNOON, RE_MORNING, RE_EVENING, RE_NIGHT), - "evening": (RE_EVENING, RE_MORNING, RE_AFTERNOON, RE_NIGHT), - "night": (RE_NIGHT, RE_MORNING, RE_AFTERNOON, RE_EVENING), -} -# set up the seasons and time slots. This assumes gametime started at the -# beginning of the year (so month 1 is equivalent to January), and that -# one CAN divide the game's year into four seasons in the first place ... -MONTHS_PER_YEAR = 12 -SEASONAL_BOUNDARIES = (3 / 12.0, 6 / 12.0, 9 / 12.0) -HOURS_PER_DAY = 24 -DAY_BOUNDARIES = (0, 6 / 24.0, 12 / 24.0, 18 / 24.0) +# funcparser callable for the ExtendedRoom -# implements the Extended Room +def func_state(roomstate, *args, looker=None, room=None, **kwargs): + """ + Usage: $state(roomstate, text) + + Funcparser callable for ExtendedRoom. This is called by the FuncParser when it + returns the description of the room. Use 'default' for a default text when no + other states are set. + + Args: + roomstate (str): A roomstate, like "morning", "raining". This is case insensitive. + *args: All these will be combined into one string separated by commas. + + Keyword Args: + looker (Object): The object looking at the room. Unused by default. + room (ExtendedRoom): The room being looked at. + + Example: + + $state(morning, It is a beautiful morning!) + + Notes: + We try to merge all args into one text, since this function doesn't require more than one + argument. That way, one may be able to get away without using quotes. + + """ + roomstate = str(roomstate).lower() + text = ", ".join(args) + # make sure we have a room and a caller and not something parsed from the string + if not (roomstate and looker and room) or isinstance(looker, str) or isinstance(room, str): + return "" + + try: + if roomstate in room.room_states or roomstate == room.get_time_of_day(): + return text + if roomstate == "default" and not room.room_states: + # return this if no roomstate is set + return text + except AttributeError: + # maybe used on a non-ExtendedRoom? + pass + return "" class ExtendedRoom(DefaultRoom): """ - This room implements a more advanced `look` functionality depending on - time. It also allows for "details", together with a slightly modified - look command. + An Extended Room + + Room states: + A room state is set as a Tag with category "roomstate" and tagkey "on_fire" or "flooded" + etc). + + Alternative descriptions: + - Add an Attribute `desc_` to the room, where is the name of the + roomstate to use this for, like `desc_on_fire` or `desc_flooded`. If not given, seasonal + descriptions given in desc_spring/summer/autumn/winter will be used, and last the + regular `desc` Attribute. + + Alternative text sections + - Used to add alternative text sections to the room description. These are embedded in the + description by adding `$state(roomstate, txt)`. They will show only if the room is in the + given roomstate. These are managed via the add/remove/get_alt_text methods. + + Details: + - This is set as an Attribute `details` (a dict) on the room, with the detail name as key. + When looking at this room, the detail name can be used as a target to look at without having + to add an actual database object for it. The `detail` command is used to add/remove details. + + Room messages + - Set `room_message_rate > 0` and add a list of `room_messages`. These will be randomly + echoed to the room at the given rate. + """ - def at_object_creation(self): - """Called when room is first created only.""" - self.db.spring_desc = "" - self.db.summer_desc = "" - self.db.autumn_desc = "" - self.db.winter_desc = "" - # the general desc is used as a fallback if a seasonal one is not set - self.db.general_desc = "" - # will be set dynamically. Can contain raw timeslot codes - self.db.raw_desc = "" - # this will be set dynamically at first look. Parsed for timeslot codes - self.db.desc = "" - # these will be filled later - self.ndb.last_season = None - self.ndb.last_timeslot = None - # detail storage - self.db.details = {} + # tag room_state category + room_state_tag_category = "room_state" - def get_time_and_season(self): + # time setup + months_per_year = 12 + hours_per_day = 24 + + # seasons per year, given as (start, end) boundaries, each a fraction of a year. These + # will change the description. The last entry should wrap around to the first. + seasons_per_year = { + "spring": (3 / months_per_year, 6 / months_per_year), # March - May + "summer": (6 / months_per_year, 9 / months_per_year), # June - August + "autumn": (9 / months_per_year, 12 / months_per_year), # September - November + "winter": (12 / months_per_year, 3 / months_per_year), # December - February + } + + # time-dependent room descriptions (these must match the `seasons_per_year` above). + desc_spring = AttributeProperty("", autocreate=False) + desc_summer = AttributeProperty("", autocreate=False) + desc_autumn = AttributeProperty("", autocreate=False) + desc_winter = AttributeProperty("", autocreate=False) + + # time-dependent embedded descriptions, usable as $timeofday(morning, text) + # (start, end) boundaries, each a fraction of a day. The last one should + # end at 0 (not 24) to wrap around to midnight. + times_of_day = { + "night": (0, 6 / hours_per_day), # midnight - 6AM + "morning": (6 / hours_per_day, 12 / hours_per_day), # 6AM - noon + "afternoon": (12 / hours_per_day, 18 / hours_per_day), # noon - 6PM + "evening": (18 / hours_per_day, 0), # 6PM - midnight + } + + # normal vanilla description if no other `*_desc` matches or are set. + desc = AttributeProperty("", autocreate=False) + + # look-targets without database objects + details = AttributeProperty(dict, autocreate=False) + + # messages to send to the room + room_message_rate = 0 # set >0s to enable + room_messages = AttributeProperty(list, autocreate=False) + + # Broadcast message + + def _get_funcparser(self, looker): + return FuncParser( + {"state": func_state}, + looker=looker, + room=self, + ) + + def _start_broadcast_repeat_task(self): + if self.random_message_rate and self.random_messages and not self.ndb.broadcast_repeat_task: + self.ndb.broadcast_repeat_task = repeat( + self.random_message_rate, self.repeat_broadcast_msg_to_room, persistent=False + ) + + def at_init(self): + """Evennia hook. Start up repeating function whenever object loads into memory.""" + self._start_broadcast_repeat_task() + + def start_repeat_broadcast_messages(self): """ - Calculate the current time and season ids. + Start repeating the broadcast messages. Only needs to be called if adding messages + and not having reloaded the server. + + """ + self._start_broadcast_repeat_task() + + def repeat_broadcast_message_to_room(self): + """ + Send a message to the room at room_message_rate. By default + we will randomize which one to send. + + """ + self.msg_contents(random.choice(self.room_messages)) + + def get_time_of_day(self): + """ + Get the current time of day. + + Override to customize. + + Returns: + str: The time of day, such as 'morning', 'afternoon', 'evening' or 'night'. + """ - # get the current time as parts of year and parts of day. - # we assume a standard calendar here and use 24h format. timestamp = gametime.gametime(absolute=True) - # note that fromtimestamp includes the effects of server time zone! datestamp = datetime.datetime.fromtimestamp(timestamp) - season = float(datestamp.month) / MONTHS_PER_YEAR - timeslot = float(datestamp.hour) / HOURS_PER_DAY + timeslot = float(datestamp.hour) / self.hours_per_day - # figure out which slots these represent - if SEASONAL_BOUNDARIES[0] <= season < SEASONAL_BOUNDARIES[1]: - curr_season = "spring" - elif SEASONAL_BOUNDARIES[1] <= season < SEASONAL_BOUNDARIES[2]: - curr_season = "summer" - elif SEASONAL_BOUNDARIES[2] <= season < 1.0 + SEASONAL_BOUNDARIES[0]: - curr_season = "autumn" - else: - curr_season = "winter" + for time_of_day, (start, end) in self.times_of_day.items(): + if start < end and start <= timeslot < end: + return time_of_day + return time_of_day # final back to the beginning - if DAY_BOUNDARIES[0] <= timeslot < DAY_BOUNDARIES[1]: - curr_timeslot = "night" - elif DAY_BOUNDARIES[1] <= timeslot < DAY_BOUNDARIES[2]: - curr_timeslot = "morning" - elif DAY_BOUNDARIES[2] <= timeslot < DAY_BOUNDARIES[3]: - curr_timeslot = "afternoon" - else: - curr_timeslot = "evening" - - return curr_season, curr_timeslot - - def replace_timeslots(self, raw_desc, curr_time): + def get_season(self): """ - Filter so that only time markers `...` of - the correct timeslot remains in the description. + Get the current season. - Args: - raw_desc (str): The unmodified description. - curr_time (str): A timeslot identifier. + Override to customize. Returns: - description (str): A possibly moified description. + str: The season, such as 'spring', 'summer', 'autumn' or 'winter'. """ - if raw_desc: - regextuple = REGEXMAP[curr_time] - raw_desc = regextuple[0].sub(r"\1", raw_desc) - raw_desc = regextuple[1].sub("", raw_desc) - raw_desc = regextuple[2].sub("", raw_desc) - return regextuple[3].sub("", raw_desc) - return raw_desc + timestamp = gametime.gametime(absolute=True) + datestamp = datetime.datetime.fromtimestamp(timestamp) + timeslot = float(datestamp.month) / self.months_per_year - def return_detail(self, key): + for season_of_year, (start, end) in self.seasons_per_year.items(): + if start < end and start <= timeslot < end: + return season_of_year + return season_of_year # final step is back to beginning + + # manipulate room states + + @property + def room_states(self): """ - This will attempt to match a "detail" to look for in the room. + Get all room_states set on this room. + + """ + return self.tags.get(category=self.room_state_tag_category, return_list=True) + + def add_room_state(self, *room_states): + """ + Set a room-state or room-states to the room. Args: - key (str): A detail identifier. - - Returns: - detail (str or None): A detail matching the given key. + *room_state (str): A room state like 'on_fire' or 'flooded'. This will affect + what `desc_*` and `roomstate_*` descriptions/inlines are used. You can add + more than one at a time. Notes: - A detail is a way to offer more things to look at in a room - without having to add new objects. For this to work, we - require a custom `look` command that allows for `look - ` - the look command should defer to this method on - the current location (if it exists) before giving up on - finding the target. + You can also set time-based room_states this way, like 'morning' or 'spring'. This + can be useful to force a particular description, but while this state is + set this way, that state will be unaffected by the passage of time. Remove + the state to let the current game time determine this type of states. - Details are not season-sensitive, but are parsed for timeslot - markers. """ - try: - detail = self.db.details.get(key.lower(), None) - except AttributeError: - # this happens if no attribute details is set at all - return None - if detail: - season, timeslot = self.get_time_and_season() - detail = self.replace_timeslots(detail, timeslot) - return detail - return None + self.tags.batch_add(*((state, self.room_state_tag_category) for state in room_states)) - def set_detail(self, detailkey, description): + def remove_room_state(self, *room_states): + """ + Remove a roomstate from the room. + + Args: + *room_state (str): A roomstate like 'on_fire' or 'flooded'. If the + room did not have this state, nothing happens.You can remove more than one at a time. + + """ + for room_state in room_states: + self.tags.remove(room_state, category=self.room_state_tag_category) + + def clear_room_state(self): + """ + Clear all room states. + + Note that fallback time-of-day and seasonal states are not affected by this, only + custom states added with `.add_room_state()`. + + """ + self.tags.clear(category="room_state") + + # control the available room descriptions + + def add_desc(self, desc, room_state=None): + """ + Add a custom description, matching a particular room state. + + Args: + desc (str): The description to use when this roomstate is active. + roomstate (str, None): The roomstate to match, like 'on_fire', 'flooded', or "spring". + If `None`, set the default `desc` fallback. + + """ + if room_state is None: + self.attributes.add("desc", desc) + else: + self.attributes.add(f"desc_{room_state}", desc) + + def remove_desc(self, room_state): + """ + Remove a custom description. + + Args: + room_state (str): The room-state description to remove. + + """ + self.attributes.remove(f"desc_{room_state}") + + def all_desc(self): + """ + Get all available descriptions. + + Returns: + dict: A mapping of roomstate to description. The `None` key indicates the + base subscription (stored in the `desc` Attribute). + + """ + return { + **{None: self.db.desc or ""}, + **{ + attr.key[5:]: attr.value + for attr in self.db_attributes.filter(db_key__startswith="desc_") + }, + } + + def get_stateful_desc(self): + """ + Get the currently active room description based on the current roomstate. + + Returns: + str: The current description. + + Note: + Only one description can be active at a time. Priority order is as follows: + + Priority order is as follows: + + 1. Room-states set by `add_roomstate()` that are not seasons. + If multiple room_states are set, the first one is used, sorted alphabetically. + 2. Seasons set by `add_room_state()`. This allows to 'pin' a season. + 3. Time-based seasons based on the current in-game time. + 4. None, if no seasons are defined in `.seasons_per_year`. + + If either of the above is found, but doesn't have a matching `desc_` + description, we move on to the next priority. If no matches are found, the `desc` + Attribute is used. + + """ + + room_states = self.room_states + seasons = self.seasons_per_year.keys() + seasonal_room_states = [] + + # get all available descriptions on this room + # note: *_desc is the old form, we support it for legacy + descriptions = dict( + self.db_attributes.filter( + Q(db_key__startswith="desc_") | Q(db_key__endswith="_desc") + ).values_list("db_key", "db_value") + ) + + for roomstate in sorted(room_states): + if roomstate not in seasons: + # if we have a roomstate that is not a season, use it + if desc := descriptions.get(f"desc_{roomstate}") or descriptions.get( + "{roomstate}_desc" + ): + return desc + else: + seasonal_room_states.append(roomstate) + + if not seasons: + # no seasons defined, so just return the default desc + return self.attributes.get("desc") + + for seasonal_roomstate in seasonal_room_states: + # explicit setting of season outside of automatic time keeping + if desc := descriptions.get(f"desc_{seasonal_roomstate}"): + return desc + + # no matching room_states, use time-based seasons. Also support legacy *_desc form + season = self.get_season() + if desc := descriptions.get(f"desc_{season}") or descriptions.get(f"{season}_desc"): + return desc + + # fallback to normal desc Attribute + return self.attributes.get("desc") + + def replace_legacy_time_of_day_markup(self, desc): + """ + Filter description by legacy markup like `...`. Filter + out all such markings that does not match the current time. Supports + 'morning', 'afternoon', 'evening' and 'night'. + + Args: + desc (str): The unmodified description. + + Returns: + str: A possibly modified description. + + Notes: + This is legacy. Use the $state markup for new rooms instead. + + """ + time_of_day = self.get_time_of_day() + + # regexes for in-desc replacements (gets cached) + if not hasattr(self, "legacy_timeofday_regex_map"): + timeslots = deque() + for time_of_day in self.times_of_day: + timeslots.append( + ( + time_of_day, + re.compile(rf"<{time_of_day}>(.*?)", re.IGNORECASE), + ) + ) + + # map the regexes cyclically, so each one is first once + self.legacy_timeofday_regex_map = {} + for i in range(len(timeslots)): + # mapping {"morning": [morning_regex, ...], ...} + self.legacy_timeofday_regex_map[timeslots[0][0]] = [tup[1] for tup in timeslots] + timeslots.rotate(-1) + + # do the replacement + regextuple = self.legacy_timeofday_regex_map[time_of_day] + desc = regextuple[0].sub(r"\1", desc) + desc = regextuple[1].sub("", desc) + desc = regextuple[2].sub("", desc) + return regextuple[3].sub("", desc) + + def get_display_desc(self, looker, **kwargs): + """ + Evennia standard hook. Dynamically get the 'desc' component of the object description. This + is called by the return_appearance method and in turn by the 'look' command. + + Args: + looker (Object): Object doing the looking (unused by default). + **kwargs: Arbitrary data for use when overriding. + Returns: + str: The desc display string. + + """ + # get the current description based on the roomstate + desc = self.get_stateful_desc() + # parse for legacy ... markers + desc = self.replace_legacy_time_of_day_markup(desc) + # apply funcparser + desc = self._get_funcparser(looker).parse(desc, **kwargs) + return desc + + # manipulate details + + def add_detail(self, key, description): """ This sets a new detail, using an Attribute "details". @@ -239,82 +486,83 @@ class ExtendedRoom(DefaultRoom): aliases you need to add multiple keys to the same description). Case-insensitive. description (str): The text to return when looking - at the given detailkey. + at the given detailkey. This can contain funcparser directives. """ - if self.db.details: - self.db.details[detailkey.lower()] = description - else: - self.db.details = {detailkey.lower(): description} + if not self.details: + self.details = {} # causes it to be created as real attribute + self.details[key.lower()] = description - def del_detail(self, detailkey, description): + set_detail = add_detail # legacy name + + def remove_detail(self, key, *args): """ Delete a detail. - The description is ignored. - Args: - detailkey (str): the detail to remove (case-insensitive). - description (str, ignored): the description. + key (str): the detail to remove (case-insensitive). + *args: Unused (backwards compatibility) The description is only included for compliance but is completely ignored. Note that this method doesn't raise any exception if the detail doesn't exist in this room. """ - if self.db.details and detailkey.lower() in self.db.details: - del self.db.details[detailkey.lower()] + self.details.pop(key.lower(), None) - def return_appearance(self, looker, **kwargs): + del_detail = remove_detail # legacy alias + + def get_detail(self, key, looker=None): """ - This is called when e.g. the look command wants to retrieve - the description of this object. + This will attempt to match a "detail" to look for in the room. + This will do a lower-case match followed by a startsby match. This + is called by the new `look` Command. Args: - looker (Object): The object looking at us. - **kwargs (dict): Arbitrary, optional arguments for users - overriding the call (unused by default). + key (str): A detail identifier. + looker (Object, optional): The one looking. Returns: - description (str): Our description. + detail (str or None): A detail matching the given key, or `None` if + it was not found. + + Notes: + A detail is a way to offer more things to look at in a room + without having to add new objects. For this to work, we + require a custom `look` command that allows for `look ` + - the look command should defer to this method on + the current location (if it exists) before giving up on + finding the target. """ - # ensures that our description is current based on time/season - self.update_current_description() - # run the normal return_appearance method, now that desc is updated. - return super().return_appearance(looker, **kwargs) + key = key.lower() + detail_keys = tuple(self.details.keys()) - def update_current_description(self): - """ - This will update the description of the room if the time or season - has changed since last checked. - """ - update = False - # get current time and season - curr_season, curr_timeslot = self.get_time_and_season() - # compare with previously stored slots - last_season = self.ndb.last_season - last_timeslot = self.ndb.last_timeslot - if curr_season != last_season: - # season changed. Load new desc, or a fallback. - new_raw_desc = self.attributes.get("%s_desc" % curr_season) - if new_raw_desc: - raw_desc = new_raw_desc - else: - # no seasonal desc set. Use fallback - raw_desc = self.db.general_desc or self.db.desc - self.db.raw_desc = raw_desc - self.ndb.last_season = curr_season - update = True - if curr_timeslot != last_timeslot: - # timeslot changed. Set update flag. - self.ndb.last_timeslot = curr_timeslot - update = True - if update: - # if anything changed we have to re-parse - # the raw_desc for time markers - # and re-save the description again. - self.db.desc = self.replace_timeslots(self.db.raw_desc, curr_timeslot) + detail = None + if key in detail_keys: + # exact match + detail = self.details[key] + else: + # find closest match starting with key (shortest difference in length) + lkey = len(key) + startswith_matches = sorted( + ( + (detail_key, abs(lkey - len(detail_key))) + for detail_key in detail_keys + if detail_key.startswith(key) + ), + key=lambda tup: tup[1], + ) + if startswith_matches: + # use the matching startswith-detail with the shortest difference in length + detail = self.details[startswith_matches[0][0]] + + if detail: + detail = self._get_funcparser(looker).parse(detail) + + return detail + + return_detail = get_detail # legacy name # Custom Look command supporting Room details. Add this to @@ -334,156 +582,233 @@ class CmdExtendedRoomLook(default_cmds.CmdLook): Observes your location, details at your location or objects in your vicinity. """ - def func(self): + def look_detail(self): """ - Handle the looking - add fallback to details. + Look for detail on room. """ caller = self.caller - args = self.args - if args: - looking_at_obj = caller.search( - args, - candidates=caller.location.contents + caller.contents, - use_nicks=True, - quiet=True, - ) - if not looking_at_obj: - # no object found. Check if there is a matching - # detail at location. - location = caller.location - if ( - location - and hasattr(location, "return_detail") - and callable(location.return_detail) - ): - detail = location.return_detail(args) - if detail: - # we found a detail - # tell all the objects in the room we're looking closely at something - caller.location.msg_contents( - f"$You() $conj(look) closely at {args}.\n", from_obj=caller - ) - # show the detail to the player - caller.msg(detail) - return - # no detail found. Trigger delayed error messages - _AT_SEARCH_RESULT(looking_at_obj, caller, args, quiet=False) - return - else: - # we need to extract the match manually. - looking_at_obj = utils.make_iter(looking_at_obj)[0] - else: - looking_at_obj = caller.location - if not looking_at_obj: + if hasattr(self.caller.location, "get_detail"): + detail = self.caller.location.get_detail(self.args, looker=self.caller) + if detail: + caller.location.msg_contents( + f"$You() $conj(look) closely at {self.args}.\n", + from_obj=caller, + exclude=caller, + ) + caller.msg(detail) + return True + return False + + def func(self): + """ + Handle the looking. + """ + caller = self.caller + if not self.args: + target = caller.location + if not target: caller.msg("You have no location to look at!") return - - if not hasattr(looking_at_obj, "return_appearance"): - # this is likely due to us having an account instead - looking_at_obj = looking_at_obj.character - if not looking_at_obj.access(caller, "view"): - caller.msg("Could not find '%s'." % args) - return - # get object's appearance - caller.msg(looking_at_obj.return_appearance(caller)) - # the object's at_desc() method. - looking_at_obj.at_desc(looker=caller) + else: + # search, waiting to return errors so we can also check details + target = caller.search(self.args, quiet=True) + if not target and not self.look_detail(): + _AT_SEARCH_RESULT(target, caller, self.args, quiet=False) + return + desc = caller.at_look(target) + # add the type=look to the outputfunc to make it + # easy to separate this output in client. + self.msg(text=(desc, {"type": "look"}), options=None) # Custom build commands for setting seasonal descriptions # and detailing extended rooms. +def _desc_load(caller): + return caller.db.eveditor_target.db.desc or "" + + +def _desc_save(caller, buf): + """ + Save line buffer to the desc prop. This should + return True if successful and also report its status to the user. + """ + roomstates = caller.db.eveditor_roomstates + target = caller.db.eveditor_target + + if not roomstates or not hasattr(target, "add_desc"): + # normal description + target.db.desc = buf + elif roomstates: + for roomstate in roomstates: + target.add_desc(buf, room_state=roomstate) + else: + target.db.desc = buf + + caller.msg("Saved.") + return True + + +def _desc_quit(caller): + caller.attributes.remove("eveditor_target") + caller.msg("Exited editor.") + + class CmdExtendedRoomDesc(default_cmds.CmdDesc): """ - `desc` - describe an object or room. + describe an object or the current room. Usage: - desc[/switch] [ =] + @desc[/switch] [ =] - Switches for `desc`: - spring - set description for in current room. - summer - autumn - winter + Switches: + edit - Open up a line editor for more advanced editing. + del - Delete the description of an object. If another state is given, its description + will be deleted. + spring|summer|autumn|winter - room description to use in respective in-game season + - room description to use with an arbitrary room state. - Sets the "desc" attribute on an object. If an object is not given, - describe the current room. + Sets the description an object. If an object is not given, + describe the current room, potentially showing any additional stateful descriptions. The room + states only work with rooms. - You can also embed special time markers in your room description, like this: + Examples: + @desc/winter A cold winter scene. + @desc/edit/summer + @desc/burning This room is burning! + @desc A normal room with no state. + @desc/del/burning - ``` - In the darkness, the forest looks foreboding.. - ``` - - Text marked this way will only display when the server is truly at the given - timeslot. The available times are night, morning, afternoon and evening. - - Note that seasons and time-of-day slots only work on rooms in this - version of the `desc` command. + Rooms will automatically change season as the in-game time changes. You can + set a specific room-state with the |wroomstate|n command. """ - aliases = ["describe"] - switch_options = () # Inherits from default_cmds.CmdDesc, but unused here + key = "@desc" + switch_options = None + locks = "cmd:perm(desc) or perm(Builder)" + help_category = "Building" - def reset_times(self, obj): - """By deleteting the caches we force a re-load.""" - obj.ndb.last_season = None - obj.ndb.last_timeslot = None + def parse(self): + super().parse() + + self.delete_mode = "del" in self.switches + self.edit_mode = not self.delete_mode and "edit" in self.switches + + self.object_mode = "=" in self.args + + # all other switches are names of room-states + self.roomstates = [state for state in self.switches if state not in ("edit", "del")] + + def edit_handler(self): + if self.rhs: + self.msg("|rYou may specify a value, or use the edit switch, but not both.|n") + return + if self.args: + obj = self.caller.search(self.args) + else: + obj = self.caller.location or self.msg("|rYou can't describe oblivion.|n") + if not obj: + return + + if not (obj.access(self.caller, "control") or obj.access(self.caller, "edit")): + self.caller.msg(f"You don't have permission to edit the description of {obj.key}.") + return + + self.caller.db.eveditor_target = obj + self.caller.db.eveditor_roomstates = self.roomstates + # launch the editor + EvEditor( + self.caller, + loadfunc=_desc_load, + savefunc=_desc_save, + quitfunc=_desc_quit, + key="desc", + persistent=True, + ) + return + + def show_stateful_descriptions(self): + location = self.caller.location + room_states = location.room_states + season = location.get_season() + time_of_day = location.get_time_of_day() + stateful_descs = location.all_desc() + + output = [ + f"Room {location.get_display_name(self.caller)} " + f"Season: {season}. Time: {time_of_day}. " + f"States: {', '.join(room_states) if room_states else 'None'}" + ] + other_active = False + for state, desc in stateful_descs.items(): + if state is None: + continue + if state == season or state in room_states: + output.append(f"Room state |w{state}|n |g(active)|n:\n{desc}") + other_active = True + else: + output.append(f"Room state |w{state}|n:\n{desc}") + + active = " |g(active)|n" if not other_active else "" + output.append(f"Room state |w(default)|n{active}:\n{location.db.desc}") + + sep = "\n" + "-" * 78 + "\n" + self.caller.msg(sep.join(output)) def func(self): - """Define extended command""" caller = self.caller - location = caller.location - if not self.args: - if location: - string = "|wDescriptions on %s|n:\n" % location.key - string += " |wspring:|n %s\n" % location.db.spring_desc - string += " |wsummer:|n %s\n" % location.db.summer_desc - string += " |wautumn:|n %s\n" % location.db.autumn_desc - string += " |wwinter:|n %s\n" % location.db.winter_desc - string += " |wgeneral:|n %s" % location.db.general_desc - caller.msg(string) + if not self.args and "edit" not in self.switches and "del" not in self.switches: + if caller.location: + # show stateful descs on the room + self.show_stateful_descriptions() return - if self.switches and self.switches[0] in ("spring", "summer", "autumn", "winter"): - # a seasonal switch was given - if self.rhs: - caller.msg("Seasonal descs only work with rooms, not objects.") + else: + caller.msg("You have no location to describe!") return - switch = self.switches[0] - if not location: - caller.msg("No location was found!") + + if self.edit_mode: + self.edit_handler() + return + + if self.object_mode: + # We are describing an object + target = caller.search(self.lhs) + if not target: return - if switch == "spring": - location.db.spring_desc = self.args - elif switch == "summer": - location.db.summer_desc = self.args - elif switch == "autumn": - location.db.autumn_desc = self.args - elif switch == "winter": - location.db.winter_desc = self.args - # clear flag to force an update - self.reset_times(location) - caller.msg("Seasonal description was set on %s." % location.key) + desc = self.rhs or "" else: - # No seasonal desc set, maybe this is not an extended room - if self.rhs: - text = self.rhs - obj = caller.search(self.lhs) - if not obj: - return + # we are describing the current room + target = caller.location or self.msg("|rYou don't have a location to describe.|n") + if not target: + return + desc = self.args + + roomstates = self.roomstates + if target.access(self.caller, "control") or target.access(self.caller, "edit"): + if not roomstates or not hasattr(target, "add_desc"): + # normal description + target.db.desc = desc + elif roomstates: + for roomstate in roomstates: + if self.delete_mode: + target.remove_desc(roomstate) + caller.msg(f"The {roomstate}-description was deleted, if it existed.") + else: + target.add_desc(desc, room_state=roomstate) + caller.msg( + f"The {roomstate}-description was set on" + f" {target.get_display_name(caller)}." + ) else: - text = self.args - obj = location - obj.db.desc = text # a compatibility fallback - if obj.attributes.has("general_desc"): - obj.db.general_desc = text - self.reset_times(obj) - caller.msg("General description was set on %s." % obj.key) - else: - # this is not an ExtendedRoom. - caller.msg("The description was set on %s." % obj.key) + target.db.desc = desc + caller.msg(f"The description was set on {target.get_display_name(caller)}.") + else: + caller.msg( + "You don't have permission to edit the description " + f"of {target.get_display_name(caller)}." + ) class CmdExtendedRoomDetail(default_cmds.MuxCommand): @@ -521,7 +846,10 @@ class CmdExtendedRoomDetail(default_cmds.MuxCommand): if not self.args: details = location.db.details if not details: - self.msg("|rThe room {} doesn't have any detail set.|n".format(location)) + self.msg( + f"|rThe room {location.get_display_name(self.caller)} doesn't have any" + " details.|n" + ) else: details = sorted(["|y{}|n: {}".format(key, desc) for key, desc in details.items()]) self.msg("Details on Room:\n" + "\n".join(details)) @@ -535,7 +863,7 @@ class CmdExtendedRoomDetail(default_cmds.MuxCommand): self.msg("Detail '{}' not found.".format(self.lhs)) return - method = "set_detail" if "del" not in self.switches else "del_detail" + method = "add_detail" if "del" not in self.switches else "remove_detail" if not hasattr(location, method): self.caller.msg("Details cannot be set on %s." % location) return @@ -544,39 +872,99 @@ class CmdExtendedRoomDetail(default_cmds.MuxCommand): # the one key to loop over) getattr(location, method)(key, self.rhs) if "del" in self.switches: - self.caller.msg("Detail %s deleted, if it existed." % self.lhs) + self.caller.msg(f"Deleted detail '{self.lhs}', if it existed.") else: - self.caller.msg("Detail set '%s': '%s'" % (self.lhs, self.rhs)) + self.caller.msg(f"Set detail '{self.lhs}': '{self.rhs}'") -# Simple command to view the current time and season +class CmdExtendedRoomState(default_cmds.MuxCommand): + """ + Toggle and view room state for the current room. + + Usage: + @roomstate [] + + Examples: + @roomstate spring + @roomstate burning + @roomstate burning (a second time toggles it off) + + If the roomstate was already set, it will be disabled. Use + without arguments to see the roomstates on the current room. + + """ + + key = "@roomstate" + locks = "cmd:perm(Builder)" + help_category = "Building" + + def parse(self): + super().parse() + self.room = self.caller.location + if not self.room or not hasattr(self.room, "room_states"): + self.caller.msg("You have no current location, or it doesn't support room states.") + raise InterruptCommand() + + self.room_state = self.args.strip().lower() + + def func(self): + caller = self.caller + room = self.room + room_state = self.room_state + + if room_state: + # toggle room state + if room_state in room.room_states: + room.remove_room_state(room_state) + caller.msg(f"Cleared room state '{room_state}' from this room.") + else: + room.add_room_state(room_state) + caller.msg(f"Added room state '{room_state}' to this room.") + else: + # view room states + room_states = list_to_string( + [f"'{state}'" for state in room.room_states] if room.room_states else ("None",) + ) + caller.msg( + "Room states (not counting automatic time/season) on" + f" {room.get_display_name(caller)}:\n {room_states}" + ) class CmdExtendedRoomGameTime(default_cmds.MuxCommand): """ - Check the game time + Check the game time. Usage: time Shows the current in-game time and season. + """ key = "time" locks = "cmd:all()" help_category = "General" - def func(self): - """Reads time info from current room""" + def parse(self): location = self.caller.location - if not location or not hasattr(location, "get_time_and_season"): + if ( + not location + or not hasattr(location, "get_time_of_day") + or not hasattr(location, "get_season") + ): self.caller.msg("No location available - you are outside time.") - else: - season, timeslot = location.get_time_and_season() - prep = "a" - if season == "autumn": - prep = "an" - self.caller.msg("It's %s %s day, in the %s." % (prep, season, timeslot)) + raise InterruptCommand() + self.location = location + + def func(self): + location = self.location + + season = location.get_season() + timeslot = location.get_time_of_day() + + prep = "a" if season == "autumn" else "an" + self.caller.msg(f"It's {prep} {season} day, in the {timeslot}.") # CmdSet for easily install all commands @@ -589,7 +977,8 @@ class ExtendedRoomCmdSet(CmdSet): """ def at_cmdset_creation(self): - self.add(CmdExtendedRoomLook) - self.add(CmdExtendedRoomDesc) - self.add(CmdExtendedRoomDetail) - self.add(CmdExtendedRoomGameTime) + self.add(CmdExtendedRoomLook()) + self.add(CmdExtendedRoomDesc()) + self.add(CmdExtendedRoomDetail()) + self.add(CmdExtendedRoomState()) + self.add(CmdExtendedRoomGameTime()) diff --git a/evennia/contrib/grid/extended_room/tests.py b/evennia/contrib/grid/extended_room/tests.py index d5ec1e0f29..8b86d1d9f2 100644 --- a/evennia/contrib/grid/extended_room/tests.py +++ b/evennia/contrib/grid/extended_room/tests.py @@ -6,118 +6,392 @@ Testing of ExtendedRoom contrib import datetime from django.conf import settings +from evennia import create_object +from evennia.utils.test_resources import BaseEvenniaCommandTest, EvenniaTestCase from mock import Mock, patch - -from evennia.commands.default.tests import BaseEvenniaCommandTest -from evennia.objects.objects import DefaultRoom +from parameterized import parameterized from . import extended_room -class ForceUTCDatetime(datetime.datetime): +def _get_timestamp(season, time_of_day): + """ + Utility to get a timestamp for a given season and time of day. - """Force UTC datetime.""" - - @classmethod - def fromtimestamp(cls, timestamp): - """Force fromtimestamp to run with naive datetimes.""" - return datetime.datetime.utcfromtimestamp(timestamp) + """ + # grab a month / time given a season and time of day + seasons = {"spring": 3, "summer": 6, "autumn": 9, "winter": 12} + times_of_day = {"morning": 6, "afternoon": 12, "evening": 18, "night": 0} + # return a datetime object for the 1st of the month at the given hour + return datetime.datetime(2064, seasons[season], 1, times_of_day[time_of_day]).timestamp() -@patch("evennia.contrib.grid.extended_room.extended_room.datetime.datetime", ForceUTCDatetime) -# mock gametime to return April 9, 2064, at 21:06 (spring evening) -@patch("evennia.utils.gametime.gametime", new=Mock(return_value=2975000766)) -class TestExtendedRoom(BaseEvenniaCommandTest): - room_typeclass = extended_room.ExtendedRoom - DETAIL_DESC = "A test detail." - SPRING_DESC = "A spring description." - OLD_DESC = "Old description." - settings.TIME_ZONE = "UTC" +class TestExtendedRoom(EvenniaTestCase): + """ + Test Extended Room typeclass. + + """ + + base_room_desc = "Base room description." + + def setUp(self): + self.room = create_object(extended_room.ExtendedRoom, key="Test Room") + self.room.desc = self.base_room_desc + + def test_room_description(self): + """ + Test that the vanilla room description is returned as expected. + """ + room_desc = self.room.get_display_desc(None) + self.assertEqual(room_desc, self.base_room_desc) + + @parameterized.expand( + [ + ("spring", "Spring room description."), + ("summer", "Summer room description."), + ("autumn", "Autumn room description."), + ("winter", "Winter room description."), + ] + ) + @patch("evennia.utils.gametime.gametime") + def test_seasonal_room_descriptions(self, season, desc, mock_gametime): + """ + Test that the room description changes with the season. + """ + mock_gametime.return_value = _get_timestamp(season, "morning") + self.room.add_desc(desc, room_state=season) + + room_desc = self.room.get_display_desc(None) + self.assertEqual(room_desc, desc) + + @parameterized.expand( + [ + ("morning", "Morning room description."), + ("afternoon", "Afternoon room description."), + ("evening", "Evening room description."), + ("night", "Night room description."), + ] + ) + @patch("evennia.utils.gametime.gametime") + def test_get_time_of_day_tags(self, time_of_day, desc, mock_gametime): + """ + Test room with $ + """ + mock_gametime.return_value = _get_timestamp("spring", time_of_day) + room_time_of_day = self.room.get_time_of_day() + self.assertEqual(room_time_of_day, time_of_day) + + self.room.add_desc( + "$state(morning, Morning room description.)" + "$state(afternoon, Afternoon room description.)" + "$state(evening, Evening room description.)" + "$state(night, Night room description.)" + " What a great day!" + ) + room_desc = self.room.get_display_desc(None) + self.assertEqual(room_desc, f"{desc} What a great day!") + + def test_room_states(self): + """ + Test rooms with custom game states. + + """ + self.room.add_desc( + "$state(under_construction, This room is under construction.)" + " $state(under_repair, This room is under repair.)" + ) + self.room.add_room_state("under_construction") + self.assertEqual(self.room.room_states, ["under_construction"]) + self.assertEqual(self.room.get_display_desc(None), "This room is under construction. ") + + self.room.add_room_state("under_repair") + self.assertEqual(self.room.room_states, ["under_construction", "under_repair"]) + self.assertEqual( + self.room.get_display_desc(None), + "This room is under construction. This room is under repair.", + ) + + self.room.remove_room_state("under_construction") + self.assertEqual( + self.room.get_display_desc(None), + " This room is under repair.", + ) + + def test_alternative_descs(self): + """ + Test rooms with alternate descriptions. + + """ + self.room.add_desc("The room is burning!", room_state="burning") + self.room.add_desc("The room is flooding!", room_state="flooding") + self.assertEqual(self.room.get_display_desc(None), self.base_room_desc) + + self.room.add_room_state("burning") + self.assertEqual(self.room.get_display_desc(None), "The room is burning!") + + self.room.add_room_state("flooding") + self.room.remove_room_state("burning") + self.assertEqual(self.room.get_display_desc(None), "The room is flooding!") + + self.room.clear_room_state() + self.assertEqual(self.room.get_display_desc(None), self.base_room_desc) + + def test_details(self): + """ + Test room details. + + """ + self.room.add_detail("test", "Test detail.") + self.room.add_detail("test2", "Test detail 2.") + self.room.add_detail("window", "Window detail.") + self.room.add_detail("window pane", "Window Pane detail.") + + self.assertEqual(self.room.get_detail("test"), "Test detail.") + self.assertEqual(self.room.get_detail("test2"), "Test detail 2.") + self.assertEqual(self.room.get_detail("window"), "Window detail.") + self.assertEqual(self.room.get_detail("window pane"), "Window Pane detail.") + self.assertEqual(self.room.get_detail("win"), "Window detail.") + self.assertEqual(self.room.get_detail("window p"), "Window Pane detail.") + + self.room.remove_detail("test") + self.assertEqual(self.room.get_detail("test"), "Test detail 2.") # finding nearest + self.room.remove_detail("test2") + self.assertEqual(self.room.get_detail("test"), None) # all test* gone + + +class TestExtendedRoomCommands(BaseEvenniaCommandTest): + """ + Test the ExtendedRoom commands. + + """ + + base_room_desc = "Base room description." def setUp(self): super().setUp() - self.room1.ndb.last_timeslot = "afternoon" - self.room1.ndb.last_season = "winter" - self.room1.db.details = {"testdetail": self.DETAIL_DESC} - self.room1.db.spring_desc = self.SPRING_DESC - self.room1.db.desc = self.OLD_DESC + self.room1.swap_typeclass("evennia.contrib.grid.extended_room.ExtendedRoom") + self.room1.desc = self.base_room_desc - def test_return_appearance(self): - # get the appearance of a non-extended room for contrast purposes - old_desc = DefaultRoom.return_appearance(self.room1, self.char1) - # the new appearance should be the old one, but with the desc switched - self.assertEqual( - old_desc.replace(self.OLD_DESC, self.SPRING_DESC), - self.room1.return_appearance(self.char1), + @patch("evennia.utils.gametime.gametime") + def test_cmd_desc(self, mock_gametime): + """Test new desc command""" + + mock_gametime.return_value = _get_timestamp("autumn", "afternoon") + + # view base desc + self.call( + extended_room.CmdExtendedRoomDesc(), + "", + f""" +Room Room(#{self.room1.id}) Season: autumn. Time: afternoon. States: None + +Room state (default) (active): +Base room description. + """.strip(), ) - self.assertEqual("spring", self.room1.ndb.last_season) - self.assertEqual("evening", self.room1.ndb.last_timeslot) - def test_return_detail(self): - self.assertEqual(self.DETAIL_DESC, self.room1.return_detail("testdetail")) + # add spring desc + self.call( + extended_room.CmdExtendedRoomDesc(), + "/spring Spring description.", + "The spring-description was set on Room", + ) + self.call( + extended_room.CmdExtendedRoomDesc(), + "/burning Burning description.", + "The burning-description was set on Room", + ) + + self.call( + extended_room.CmdExtendedRoomDesc(), + "", + """ +Room Room(#1) Season: autumn. Time: afternoon. States: None + +Room state spring: +Spring description. + +Room state burning: +Burning description. + +Room state (default) (active): +Base room description. + """.strip(), + ) + + # remove a desc + self.call( + extended_room.CmdExtendedRoomDesc(), + "/del/burning/spring", + ( + "The burning-description was deleted, if it existed.|The spring-description was" + " deleted, if it existed" + ), + ) + # add autumn, which should be active + self.call( + extended_room.CmdExtendedRoomDesc(), + "/autumn Autumn description.", + "The autumn-description was set on Room", + ) + self.call( + extended_room.CmdExtendedRoomDesc(), + "", + """ +Room Room(#1) Season: autumn. Time: afternoon. States: None + +Room state autumn (active): +Autumn description. + +Room state (default): +Base room description. + """.strip(), + ) + + def test_cmd_detail(self): + """Test adding details""" + self.call( + extended_room.CmdExtendedRoomDetail(), + "test=Test detail.", + "Set detail 'test': 'Test detail.'", + ) + + self.call( + extended_room.CmdExtendedRoomDetail(), + "", + """ +Details on Room: +test: Test detail. + """.strip(), + ) + + # remove a detail + self.call( + extended_room.CmdExtendedRoomDetail(), + "/del test", + "Deleted detail 'test', if it existed.", + ) + + self.call( + extended_room.CmdExtendedRoomDetail(), + "", + f""" +The room Room(#{self.room1.id}) doesn't have any details. + """.strip(), + ) + + @patch("evennia.utils.gametime.gametime") + def test_cmd_roomstate(self, mock_gametime): + """ + Test the roomstate command + + """ + + mock_gametime.return_value = _get_timestamp("autumn", "afternoon") + + # show existing room states (season/time doesn't count) + + self.assertEqual(self.room1.room_states, []) + + self.call( + extended_room.CmdExtendedRoomState(), + "", + f"Room states (not counting automatic time/season) on Room(#{self.room1.id}):\n None", + ) + + # add room states + self.call( + extended_room.CmdExtendedRoomState(), + "burning", + "Added room state 'burning' to this room.", + ) + self.call( + extended_room.CmdExtendedRoomState(), + "windy", + "Added room state 'windy' to this room.", + ) + self.call( + extended_room.CmdExtendedRoomState(), + "", + ( + f"Room states (not counting automatic time/season) on Room(#{self.room1.id}):\n " + "'burning' and 'windy'" + ), + ) + # toggle windy + self.call( + extended_room.CmdExtendedRoomState(), + "windy", + "Cleared room state 'windy' from this room.", + ) + self.call( + extended_room.CmdExtendedRoomState(), + "", + ( + f"Room states (not counting automatic time/season) on Room(#{self.room1.id}):\n " + "'burning'" + ), + ) + # add a autumn state and make sure we override it + self.room1.add_desc("Autumn description.", room_state="autumn") + self.room1.add_desc("Spring description.", room_state="spring") + + self.assertEqual(self.room1.get_stateful_desc(), "Autumn description.") + self.call( + extended_room.CmdExtendedRoomState(), + "spring", + "Added room state 'spring' to this room.", + ) + self.assertEqual(self.room1.get_stateful_desc(), "Spring description.") + + @patch("evennia.utils.gametime.gametime") + def test_cmd_roomtime(self, mock_gametime): + """ + Test the time command + """ + + mock_gametime.return_value = _get_timestamp("autumn", "afternoon") + + self.call( + extended_room.CmdExtendedRoomGameTime(), "", "It's a autumn day, in the afternoon." + ) + + @patch("evennia.utils.gametime.gametime") + def test_cmd_look(self, mock_gametime): + """ + Test the look command. + """ + mock_gametime.return_value = _get_timestamp("autumn", "afternoon") + + autumn_desc = ( + "This is a nice autumnal forest." + "$state(morning,|_The morning sun is just rising)" + "$state(afternoon,|_The afternoon sun is shining through the trees)" + "$state(burning,|_and this place is on fire!)" + "$state(afternoon, .)" + "$state(flooded, and it's raining heavily!)" + ) + self.room1.add_desc(autumn_desc, room_state="autumn") - def test_cmdextendedlook(self): - rid = self.room1.id self.call( extended_room.CmdExtendedRoomLook(), - "here", - "Room(#{})\n{}".format(rid, self.SPRING_DESC), + "", + f"Room(#{self.room1.id})\nThis is a nice autumnal forest.", ) self.call( - extended_room.CmdExtendedRoomLook(), - "testdetail", - "You look closely at {}.\n|{}".format("testdetail", self.DETAIL_DESC) + extended_room.CmdExtendedRoomLook(), + "", + ( + f"Room(#{self.room1.id})\nThis is a nice autumnal forest. The afternoon sun is" + " shining through the trees." + ), ) + self.room1.add_room_state("burning") self.call( - extended_room.CmdExtendedRoomLook(), "nonexistent", "Could not find 'nonexistent'." + extended_room.CmdExtendedRoomLook(), + "", + ( + f"Room(#{self.room1.id})\nThis is a nice autumnal forest. The afternoon sun is" + " shining through the trees and this place is on fire!" + ), ) - - def test_cmdextendedlook_second_person(self): - # char2 is already in the same room. - # replace char2.msg with a Mock; this disables it and will catch what it is called with - self.char2.msg = Mock() - - self.call( - extended_room.CmdExtendedRoomLook(), - "testdetail" - ) - - # check what char2 saw. - self.char2.msg.assert_called_with(text=('Char looks closely at testdetail.\n', {}), from_obj=self.char1) - - def test_cmdsetdetail(self): - self.call(extended_room.CmdExtendedRoomDetail(), "", "Details on Room") - self.call( - extended_room.CmdExtendedRoomDetail(), - "thingie = newdetail with spaces", - "Detail set 'thingie': 'newdetail with spaces'", - ) - self.call(extended_room.CmdExtendedRoomDetail(), "thingie", "Detail 'thingie' on Room:\n") - self.call( - extended_room.CmdExtendedRoomDetail(), - "/del thingie", - "Detail thingie deleted, if it existed.", - cmdstring="detail", - ) - self.call(extended_room.CmdExtendedRoomDetail(), "thingie", "Detail 'thingie' not found.") - - # Test with aliases - self.call(extended_room.CmdExtendedRoomDetail(), "", "Details on Room") - self.call( - extended_room.CmdExtendedRoomDetail(), - "thingie;other;stuff = newdetail with spaces", - "Detail set 'thingie;other;stuff': 'newdetail with spaces'", - ) - self.call(extended_room.CmdExtendedRoomDetail(), "thingie", "Detail 'thingie' on Room:\n") - self.call(extended_room.CmdExtendedRoomDetail(), "other", "Detail 'other' on Room:\n") - self.call(extended_room.CmdExtendedRoomDetail(), "stuff", "Detail 'stuff' on Room:\n") - self.call( - extended_room.CmdExtendedRoomDetail(), - "/del other;stuff", - "Detail other;stuff deleted, if it existed.", - ) - self.call(extended_room.CmdExtendedRoomDetail(), "other", "Detail 'other' not found.") - self.call(extended_room.CmdExtendedRoomDetail(), "stuff", "Detail 'stuff' not found.") - - def test_cmdgametime(self): - self.call(extended_room.CmdExtendedRoomGameTime(), "", "It's a spring day, in the evening.") diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 481854e55c..fdab9aa26c 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -10,10 +10,10 @@ This is the v1.0 develop version (for ref in doc building). import time from collections import defaultdict +import evennia import inflect from django.conf import settings from django.utils.translation import gettext as _ -import evennia from evennia.commands import cmdset from evennia.commands.cmdsethandler import CmdSetHandler from evennia.objects.manager import ObjectManager @@ -72,7 +72,9 @@ class ObjectSessionHandler: ) if any(sessid for sessid in self._sessid_cache if sessid not in evennia.SESSION_HANDLER): # cache is out of sync with sessionhandler! Only retain the ones in the handler. - self._sessid_cache = [sessid for sessid in self._sessid_cache if sessid in evennia.SESSION_HANDLER] + self._sessid_cache = [ + sessid for sessid in self._sessid_cache if sessid in evennia.SESSION_HANDLER + ] self.obj.db_sessid = ",".join(str(val) for val in self._sessid_cache) self.obj.save(update_fields=["db_sessid"]) @@ -100,7 +102,8 @@ class ObjectSessionHandler: ) else: sessions = [ - evennia.SESSION_HANDLER[ssid] if ssid in evennia.SESSION_HANDLER else None for ssid in self._sessid_cache + evennia.SESSION_HANDLER[ssid] if ssid in evennia.SESSION_HANDLER else None + for ssid in self._sessid_cache ] if None in sessions: # this happens only if our cache has gone out of sync with the SessionHandler. @@ -761,7 +764,6 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): contents = [obj for obj in contents if obj not in exclude] for receiver in contents: - # actor-stance replacements outmessage = _MSG_CONTENTS_PARSER.parse( inmessage, @@ -1290,7 +1292,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): looker (Object): Object doing the looking. **kwargs: Arbitrary data for use when overriding. Returns: - str: The desc display string.. + str: The desc display string. """ return self.db.desc or "You see nothing special."