diff --git a/commands/feedables.py b/commands/feedables.py index d986690..1db3c4d 100755 --- a/commands/feedables.py +++ b/commands/feedables.py @@ -1,6 +1,5 @@ #!/usr/bin/env python - from commands.command import Command from evennia import CmdSet diff --git a/commands/lighting.py b/commands/lighting.py new file mode 100755 index 0000000..9572a54 --- /dev/null +++ b/commands/lighting.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python + +from commands.command import Command +from evennia import CmdSet + +class CmdLight(Command): + """ + Creates light where there was none. Something to burn. + """ + + key = "light" + aliases = ["burn"] + # only allow this command if command.obj is carried by caller. + locks = "cmd:holds()" + + def func(self): + """ + Implements the light command. Since this command is designed + to sit on a "lightable" object, we operate only on self.obj. + """ + + if self.obj.light(): + self.caller.msg("You light %s." % self.obj.key) + self.caller.location.msg_contents( + "%s lights %s!" % (self.caller, self.obj.key), exclude=[self.caller] + ) + else: + self.caller.msg("%s is already burning." % self.obj.key) + + +class CmdSetLight(CmdSet): + """CmdSet for the lightsource commands""" + + key = "lightsource_cmdset" + # this is higher than the dark cmdset - important! + priority = 3 + + def at_cmdset_creation(self): + """called at cmdset creation""" + self.add(CmdLight()) diff --git a/typeclasses/objects.py b/typeclasses/objects.py index 9734c2f..1cff532 100644 --- a/typeclasses/objects.py +++ b/typeclasses/objects.py @@ -9,7 +9,9 @@ with a location in the game world (like Characters, Rooms, Exits). """ from evennia.objects.objects import DefaultObject +from evennia.utils import delay, search +from commands.lighting import CmdSetLight class ObjectParent: """ @@ -215,3 +217,97 @@ class Object(ObjectParent, DefaultObject): """ pass + +# ------------------------------------------------------------- +# +# LightSource +# +# This object emits light. Once it has been turned on it +# cannot be turned off. When it burns out it will delete +# itself. +# +# This could be implemented using a single-repeat Script or by +# registering with the TickerHandler. We do it simpler by +# using the delay() utility function. This is very simple +# to use but does not survive a server @reload. Because of +# where the light matters (in the Dark Room where you can +# find new light sources easily), this is okay here. +# +# ------------------------------------------------------------- + +class LightSource(Object): + """ + This implements a light source object. + + When burned out, the object will be deleted. + """ + + def at_init(self): + """ + If this is called with the Attribute is_giving_light already + set, we know that the timer got killed by a server + reload/reboot before it had time to finish. So we kill it here + instead. This is the price we pay for the simplicity of the + non-persistent delay() method. + """ + if self.db.is_giving_light: + self.delete() + + def at_object_creation(self): + """Called when object is first created.""" + super().at_object_creation() + + self.db.is_giving_light = False + self.db.burntime = 60 * 3 # 3 minutes + # this is the default desc, it can of course be customized + # when created. + self.db.desc = "A tapered candle." + # add the Light command + self.cmdset.add_default(CmdSetLight, persistent=True) + + def _burnout(self): + """ + This is called when this light source burns out. We make no + use of the return value. + """ + # delete ourselves from the database + self.db.is_giving_light = False + try: + self.location.location.msg_contents( + "%s's %s flickers and dies." % (self.location, self.key), exclude=self.location + ) + self.location.msg("Your %s flickers and dies." % self.key) + self.location.location.check_light_state() + except AttributeError: + try: + self.location.msg_contents("A %s on the floor flickers and dies." % self.key) + self.location.location.check_light_state() + except AttributeError: + # Mainly happens if we happen to be in a None location + pass + self.delete() + + def light(self): + """ + Light this object - this is called by Light command. + """ + if self.db.is_giving_light: + return False + # burn for 3 minutes before calling _burnout + self.db.is_giving_light = True + # if we are in a dark room, trigger its light check + try: + self.location.location.check_light_state() + except AttributeError: + try: + # maybe we are directly in the room + self.location.check_light_state() + except AttributeError: + # we are in a None location + pass + finally: + # start the burn timer. When it runs out, self._burnout + # will be called. We store the deferred so it can be + # killed in unittesting. + self.deferred = delay(60 * 3, self._burnout) + return True diff --git a/typeclasses/pets.py b/typeclasses/pets.py index 4983672..ac14492 100755 --- a/typeclasses/pets.py +++ b/typeclasses/pets.py @@ -7,7 +7,7 @@ Each level of pet requires more aspects for interaction. """ -from typeclasses.objects import Object +from typeclasses.objects import Object, LightSource from commands.feedables import CmdFeed, CmdFeedSet from utils.word_list import squish @@ -107,7 +107,7 @@ class Pet(Object): # subscribe ourselves to a ticker to repeatedly call the hook # "update_weather" on this object. The interval is randomized # so as to not have all weather rooms update at the same time. - self.db.interval = random.randint(50, 70) + self.db.interval = random.randint(70, 90) TICKER_HANDLER.add(interval=self.db.interval, callback=self.update_hunger) def update_hunger(self, *args, **kwargs): @@ -120,11 +120,20 @@ class Pet(Object): """ curr = self.hunger() amount = kwargs.get('amount', self.hungry_rate) + + # self.location.msg_contents(f"Feeding a pet: {amount} {isinstance(amount, str)}-> {self.db.hunger_level} / {self.hungry_rate}") + self.db.hunger_level = self.db.hunger_level + amount if self.db.hunger_level < 0: self.db.hunger_level = 0 + # if self.db.hunger_level < 5: + # self.db.is_giving_light = False + # else: + # self.db.is_giving_light = True + if self.hunger() != curr: self.location.msg_contents("|w%s|n\n" % self.hunger_appearance()) + self.location.check_light_state() def return_appearance(self, looker, **kwargs): """ @@ -138,6 +147,11 @@ class Pet(Object): """ return f"{self.db.desc} {self.hunger_appearance()}" +# ------------------------------------------------------------ +# Fireplace, both a pet that is hungry and consumes food, +# but also emits light when it isn't starving. +# ------------------------------------------------------------ + class Fire(Pet): """ Fire in this world, is a cute fireplace pet. @@ -164,8 +178,6 @@ class Fire(Pet): } def feed(self, feeder, args): - self.update_hunger(feeder=feeder, amount=300) - now = time() last_fed = feeder.ndb.fire_last_fed # could be None if last_fed and (now - last_fed < 30): @@ -181,6 +193,15 @@ class Fire(Pet): get_up = "get up and" gets_up = "gets up and" - feeder.msg(squish(f"You {get_up} put some {adj} wood on the fire in the fireplace.")) - self.location.msg_contents(squish(f"{feeder.name} {gets_up} puts {adj} wood on the fire."), + if self.db.hunger_level < 5: + feeder.msg(squish(f"You {get_up} put some {adj} wood in the " + f"fireplace, and start a fire.")) + self.location.msg_contents(squish(f"{feeder.name} {gets_up} starts a fire."), + exclude=feeder) + else: + feeder.msg(squish(f"You {get_up} put some {adj} wood on the " + f"fire in the fireplace.")) + self.location.msg_contents(squish(f"{feeder.name} {gets_up} puts {adj} wood on the fire."), exclude=feeder) + + self.update_hunger(feeder=feeder, amount=300) diff --git a/typeclasses/rooms.py b/typeclasses/rooms.py index 2ae263c..77226be 100644 --- a/typeclasses/rooms.py +++ b/typeclasses/rooms.py @@ -7,6 +7,10 @@ Rooms are simple containers that has no location of their own. from evennia.objects.objects import DefaultRoom +from .objects import LightSource + +import random + # the system error-handling module is defined in the settings. We load the # given setting here using utils.object_from_module. This way we can use # it regardless of if we change settings later. @@ -45,3 +49,247 @@ class Room(ObjectParent, DefaultRoom): has_weather = False pass + +# ------------------------------------------------------------------------------- +# +# Dark Room - a room with states +# +# This room limits the movemenets of its denizens unless they carry an active +# LightSource object (LightSource is defined in objects.LightSource) +# +# ------------------------------------------------------------------------------- + + +DARK_MESSAGES = ( + "It is pitch black. You are likely to be eaten by a grue.", + "It's pitch black. You fumble around but cannot find anything.", + "You don't see a thing. You feel around, managing to bump your fingers hard against something. Ouch!", + "You don't see a thing! Blindly grasping the air around you, you find nothing.", + "It's totally dark here. You almost stumble over something on the floor.", + "You are completely blind. For a moment you think you hear someone breathing nearby ... " + "\n ... surely you must be mistaken.", +) + +ALREADY_LIGHTSOURCE = ( + "You don't want to stumble around in blindness anymore. You already " + "found what you need. Let's get the fireplace lit already!" +) + +FOUND_LIGHTSOURCE = ( + "Your fingers bump against a candle on a candleabra." + "You pick it up, holding it firmly. Now you just need to" + " |wlight|n it using the flint and steel you carry with you." +) + + +class CmdLookDark(Command): + """ + Look around in darkness + + Usage: + look + + Look around in the darkness, trying + to find something. + """ + + key = "look" + aliases = ["l", "feel", "search", "feel around", "fiddle"] + locks = "cmd:all()" + + def func(self): + """ + Implement the command. + + This works both as a look and a search command; there is a + random chance of eventually finding a light source. + """ + caller = self.caller + + # count how many searches we've done + nr_searches = caller.ndb.dark_searches + if nr_searches is None: + nr_searches = 0 + caller.ndb.dark_searches = nr_searches + + if nr_searches < 4 and random.random() < 0.90: + # we don't find anything + caller.msg(random.choice(DARK_MESSAGES)) + caller.ndb.dark_searches += 1 + else: + # we could have found something! + if any(obj for obj in caller.contents if utils.inherits_from(obj, LightSource)): + # we already carry a LightSource object. + caller.msg(ALREADY_LIGHTSOURCE) + else: + # don't have a light source, create a new one. + create_object(LightSource, key="candle", location=caller) + caller.msg(FOUND_LIGHTSOURCE) + + +class CmdDarkHelp(Command): + """ + Help command for the dark state. + """ + key = "help" + locks = "cmd:all()" + + def func(self): + """ + Replace the the help command with a not-so-useful help + """ + string = ( + "Can't help you until you find some light! Try looking/feeling around for something to burn. " + "You shouldn't give up even if you don't find anything right away." + ) + self.caller.msg(string) + + +class CmdDarkNoMatch(Command): + """ + This is a system command. Commands with special keys are used to + override special sitations in the game. The CMD_NOMATCH is used + when the given command is not found in the current command set (it + replaces Evennia's default behavior or offering command + suggestions) + """ + + key = syscmdkeys.CMD_NOMATCH + locks = "cmd:all()" + + def func(self): + """Implements the command.""" + self.caller.msg( + "Until you find some light, there's not much you can do. " + "Try feeling around, maybe you'll find something helpful!" + ) + + +class DarkCmdSet(CmdSet): + """ + Groups the commands of the dark room together. We also import the + default say command here so that players can still talk in the + darkness. + + We give the cmdset the mergetype "Replace" to make sure it + completely replaces whichever command set it is merged onto + (usually the default cmdset) + """ + + key = "darkroom_cmdset" + mergetype = "Replace" + priority = 2 + + def at_cmdset_creation(self): + """populate the cmdset.""" + self.add(CmdLookDark()) + self.add(CmdDarkHelp()) + self.add(CmdDarkNoMatch()) + self.add(default_cmds.CmdSay()) + self.add(default_cmds.CmdQuit()) + self.add(default_cmds.CmdHome()) + + +class DarkRoom(Room): + """A dark room. This tries to start the DarkState script on all + objects entering. The script is responsible for making sure it is + valid (that is, that there is no light source shining in the room). + + The is_lit Attribute is used to define if the room is currently lit + or not, so as to properly echo state changes. + + Since this room is meant as a sort of catch-all, we also make sure + to heal characters ending up here. + + """ + + def at_object_creation(self): + """ + Called when object is first created. + """ + super().at_object_creation() + # the room starts dark. + self.db.is_lit = False + self.cmdset.add(DarkCmdSet, persistent=True) + + def at_init(self): + """ + Called when room is first recached (such as after a reload) + """ + self.check_light_state() + + def _carries_light(self, obj): + """ + Checks if the given object carries anything that gives light. + + Note that we do NOT look for a specific LightSource typeclass, + but for the Attribute is_giving_light - this makes it easy to + later add other types of light-giving items. We also accept + if there is a light-giving object in the room overall (like if + a candle was dropped in the room or the fireplace is lit). + """ + return ( + obj.is_superuser + or obj.db.is_giving_light + or any(o for o in obj.contents if o.db.is_giving_light) + ) + + def _heal(self, character): + """ + Heal a character. + """ + health = character.db.health_max or 20 + character.db.health = health + + def check_light_state(self, exclude=None): + """ + This method checks if there are any light sources in the room. + If there isn't it makes sure to add the dark cmdset to all + characters in the room. It is called whenever characters enter + the room and also by the Light sources when they turn on. + + Args: + exclude (Object): An object to not include in the light check. + """ + if any(self._carries_light(obj) for obj in self.contents if obj != exclude): + self.locks.add("view:all()") + self.cmdset.remove(DarkCmdSet) + self.db.is_lit = True + for char in (obj for obj in self.contents if obj.has_account): + # this won't do anything if it is already removed + char.msg("The room is lit up.") + else: + # noone is carrying light - darken the room + self.db.is_lit = False + self.locks.add("view:false()") + self.cmdset.add(DarkCmdSet, persistent=True) + for char in (obj for obj in self.contents if obj.has_account): + if char.is_superuser: + char.msg("You are Superuser, so you are not affected by the dark state.") + else: + # put players in darkness + char.msg("The room is completely dark.") + + def at_object_receive(self, obj, source_location, move_type="move", **kwargs): + """ + Called when an object enters the room. + """ + if obj.has_account: + # a puppeted object, that is, a Character + self._heal(obj) + # in case the new guy carries light with them + self.check_light_state() + + def at_object_leave(self, obj, target_location, move_type="move", **kwargs): + """ + In case people leave with the light, we make sure to clear the + DarkCmdSet if necessary. This also works if they are + teleported away. + """ + # since this hook is called while the object is still in the room, + # we exclude it from the light check, to ignore any light sources + # it may be carrying. + self.check_light_state(exclude=obj) + +class DabblersRoom(Room): + pass