Reworked the build script and made the default tutorial_room more clever, using details and custom cmdsets.
This commit is contained in:
parent
f0770da672
commit
c63ae1742f
11 changed files with 767 additions and 449 deletions
|
|
@ -88,7 +88,9 @@ except (IOError, CalledProcessError):
|
||||||
|
|
||||||
def init():
|
def init():
|
||||||
"""
|
"""
|
||||||
This is called only after Evennia has fully initialized all its models.
|
This is called by the launcher only after Evennia has fully
|
||||||
|
initialized all its models. It sets up the API in a safe
|
||||||
|
environment where all models are available already.
|
||||||
"""
|
"""
|
||||||
def imp(path, variable=True):
|
def imp(path, variable=True):
|
||||||
"Helper function"
|
"Helper function"
|
||||||
|
|
@ -97,14 +99,14 @@ def init():
|
||||||
mod, fromlist = path.rsplit('.', 1)
|
mod, fromlist = path.rsplit('.', 1)
|
||||||
return __import__(mod, fromlist=[fromlist])
|
return __import__(mod, fromlist=[fromlist])
|
||||||
|
|
||||||
global DefaultPlayer, DefaultObject, DefaultGuest, DefaultCharacter, \
|
global DefaultPlayer, DefaultObject, DefaultGuest, DefaultCharacter
|
||||||
DefaultRoom, DefaultExit, DefaultChannel, DefaultScript
|
global DefaultRoom, DefaultExit, DefaultChannel, DefaultScript
|
||||||
global ObjectDB, PlayerDB, ScriptDB, ChannelDB, Msg
|
global ObjectDB, PlayerDB, ScriptDB, ChannelDB, Msg
|
||||||
global Command, CmdSet, default_cmds, syscmdkeys
|
global Command, CmdSet, default_cmds, syscmdkeys
|
||||||
global search_object, search_script, search_player, search_channel, search_help
|
global search_object, search_script, search_player, search_channel, search_help
|
||||||
global create_object, create_script, create_player, create_channel, create_message
|
global create_object, create_script, create_player, create_channel, create_message
|
||||||
global lockfuncs, tickerhandler, logger, utils, gametime, ansi, spawn, managers
|
global lockfuncs, logger, utils, gametime, ansi, spawn, managers
|
||||||
global contrib
|
global contrib, TICKER_HANDLER, OOB_HANDLER, SESSION_HANDLER, CHANNEL_HANDLER
|
||||||
|
|
||||||
from players.players import DefaultPlayer
|
from players.players import DefaultPlayer
|
||||||
from players.players import DefaultGuest
|
from players.players import DefaultGuest
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,7 @@ def get_and_merge_cmdsets(caller, session, player, obj,
|
||||||
yield [lobj.cmdset.current for lobj in local_objlist
|
yield [lobj.cmdset.current for lobj in local_objlist
|
||||||
if (lobj.cmdset.current and
|
if (lobj.cmdset.current and
|
||||||
lobj.locks.check(caller, 'call', no_superuser_bypass=True))]
|
lobj.locks.check(caller, 'call', no_superuser_bypass=True))]
|
||||||
|
print "local_obj_cmdsets:", [c.key for c in local_obj_cmdsets]
|
||||||
for cset in local_obj_cmdsets:
|
for cset in local_obj_cmdsets:
|
||||||
#This is necessary for object sets, or we won't be able to
|
#This is necessary for object sets, or we won't be able to
|
||||||
# separate the command sets from each other in a busy room.
|
# separate the command sets from each other in a busy room.
|
||||||
|
|
@ -209,6 +210,7 @@ def get_and_merge_cmdsets(caller, session, player, obj,
|
||||||
if cmdsets:
|
if cmdsets:
|
||||||
# faster to do tuple on list than to build tuple directly
|
# faster to do tuple on list than to build tuple directly
|
||||||
mergehash = tuple([id(cmdset) for cmdset in cmdsets])
|
mergehash = tuple([id(cmdset) for cmdset in cmdsets])
|
||||||
|
print "mergehash:", mergehash, mergehash in _CMDSET_MERGE_CACHE, [(c.key, c.priority) for c in cmdsets]
|
||||||
if mergehash in _CMDSET_MERGE_CACHE:
|
if mergehash in _CMDSET_MERGE_CACHE:
|
||||||
# cached merge exist; use that
|
# cached merge exist; use that
|
||||||
cmdset = _CMDSET_MERGE_CACHE[mergehash]
|
cmdset = _CMDSET_MERGE_CACHE[mergehash]
|
||||||
|
|
|
||||||
|
|
@ -694,11 +694,11 @@ class CmdStateCC(MuxCommand):
|
||||||
|
|
||||||
class CmdStateJJ(MuxCommand):
|
class CmdStateJJ(MuxCommand):
|
||||||
"""
|
"""
|
||||||
j <command number>
|
jj <command number>
|
||||||
|
|
||||||
Jump to specific command number
|
Jump to specific command number
|
||||||
"""
|
"""
|
||||||
key = "j"
|
key = "jj"
|
||||||
help_category = "BatchProcess"
|
help_category = "BatchProcess"
|
||||||
locks = "cmd:perm(batchcommands)"
|
locks = "cmd:perm(batchcommands)"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,65 +54,33 @@ class CmdLook(MuxCommand):
|
||||||
locks = "cmd:all()"
|
locks = "cmd:all()"
|
||||||
arg_regex = r"\s|$"
|
arg_regex = r"\s|$"
|
||||||
|
|
||||||
# we split up the functionality of Look a little
|
|
||||||
# since this is a command which is very common to
|
|
||||||
# overload; this makes it easy to overload different
|
|
||||||
# sections of it without overloading all.
|
|
||||||
def at_found_target(self, target):
|
|
||||||
"""
|
|
||||||
Called when a target object has been found to look at.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
target (Object): object found to look at (args have
|
|
||||||
been parsed and searched for already at this point)
|
|
||||||
|
|
||||||
The default implementation calls the return_appearance hook on
|
|
||||||
the observed target object to have it describe itself (and
|
|
||||||
possibly its contents) as well as format the description into
|
|
||||||
a suitable form.
|
|
||||||
"""
|
|
||||||
caller = self.caller
|
|
||||||
if not hasattr(target, 'return_appearance'):
|
|
||||||
# this is likely due to us having a player instead
|
|
||||||
target = target.character
|
|
||||||
if not target.access(caller, "view"):
|
|
||||||
# no permission to view this object - act as if
|
|
||||||
# it was not found at all.
|
|
||||||
caller.msg("Could not find '%s'." % self.args)
|
|
||||||
return
|
|
||||||
# get object's appearance
|
|
||||||
self.caller.msg(target.return_appearance(caller))
|
|
||||||
# the object's at_desc() method.
|
|
||||||
target.at_desc(looker=caller)
|
|
||||||
|
|
||||||
def at_not_found_target(self):
|
|
||||||
"""
|
|
||||||
Called when no target object was found to look at.
|
|
||||||
"""
|
|
||||||
if not self.args:
|
|
||||||
# this means we tried to look at location but failed. It
|
|
||||||
# usually means we are OOC.
|
|
||||||
return self.caller.msg("You have no location to look at!")
|
|
||||||
# otherwise we just return quietly.
|
|
||||||
return
|
|
||||||
|
|
||||||
def func(self):
|
def func(self):
|
||||||
"""
|
"""
|
||||||
Handle the looking.
|
Handle the looking.
|
||||||
"""
|
"""
|
||||||
caller = self.caller
|
caller = self.caller
|
||||||
if self.args:
|
args = self.args
|
||||||
target = caller.search(self.args, use_nicks=True)
|
if args:
|
||||||
if target:
|
# Use search to handle duplicate/nonexistant results.
|
||||||
return self.at_found_target(target)
|
looking_at_obj = caller.search(args, use_nicks=True)
|
||||||
else:
|
if not looking_at_obj:
|
||||||
return self.at_not_found_target()
|
return
|
||||||
else:
|
else:
|
||||||
target = caller.location
|
looking_at_obj = caller.location
|
||||||
if target:
|
if not looking_at_obj:
|
||||||
return self.at_found_target(target)
|
caller.msg("You have no location to look at!")
|
||||||
else:
|
return
|
||||||
return self.at_not_found_target()
|
|
||||||
|
if not hasattr(looking_at_obj, 'return_appearance'):
|
||||||
|
# this is likely due to us having a player 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)
|
||||||
|
|
||||||
|
|
||||||
class CmdNick(MuxCommand):
|
class CmdNick(MuxCommand):
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -9,13 +9,55 @@ import random
|
||||||
|
|
||||||
from evennia import TICKER_HANDLER
|
from evennia import TICKER_HANDLER
|
||||||
from evennia import search_object
|
from evennia import search_object
|
||||||
|
from evennia import Command, CmdSet
|
||||||
from evennia.contrib.tutorial_world import objects as tut_objects
|
from evennia.contrib.tutorial_world import objects as tut_objects
|
||||||
|
|
||||||
|
|
||||||
|
class CmdMobOnOff(Command):
|
||||||
|
"""
|
||||||
|
Activates/deactivates Mob
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
mobon <mob>
|
||||||
|
moboff <mob>
|
||||||
|
|
||||||
|
This turns the mob from active (alive) mode
|
||||||
|
to inactive (dead) mode. It is used during
|
||||||
|
building to activate the mob once it's
|
||||||
|
prepared.
|
||||||
|
"""
|
||||||
|
key = "mobon"
|
||||||
|
aliases = "moboff"
|
||||||
|
locks = "cmd:superuser()"
|
||||||
|
|
||||||
|
def func(self):
|
||||||
|
"""
|
||||||
|
Uses the mob's set_alive/set_dead methods
|
||||||
|
to turn on/off the mob."
|
||||||
|
"""
|
||||||
|
if not self.args:
|
||||||
|
self.caller.msg("Usage: mobon|moboff <mob>")
|
||||||
|
return
|
||||||
|
mob = self.caller.search(self.args)
|
||||||
|
if not mob:
|
||||||
|
return
|
||||||
|
if self.cmdname == "mobon":
|
||||||
|
mob.set_alive()
|
||||||
|
else:
|
||||||
|
mob.set_dead()
|
||||||
|
|
||||||
|
|
||||||
|
class MobCmdSet(CmdSet):
|
||||||
|
"""
|
||||||
|
Holds the admin command controlling the mob
|
||||||
|
"""
|
||||||
|
def at_cmdset_creation(self):
|
||||||
|
self.add(CmdMobOnOff())
|
||||||
|
|
||||||
class Mob(tut_objects.TutorialObject):
|
class Mob(tut_objects.TutorialObject):
|
||||||
"""
|
"""
|
||||||
This is a state-machine AI mobile. It has several states which
|
This is a state-machine AI mobile. It has several states which are
|
||||||
are controlled from setting various Attributes:
|
controlled from setting various Attributes. All default to True:
|
||||||
|
|
||||||
patrolling: if set, the mob will move randomly
|
patrolling: if set, the mob will move randomly
|
||||||
from room to room, but preferring to not return
|
from room to room, but preferring to not return
|
||||||
|
|
@ -30,18 +72,23 @@ class Mob(tut_objects.TutorialObject):
|
||||||
to flee from it, so it can enter combat. If unset,
|
to flee from it, so it can enter combat. If unset,
|
||||||
it will return to patrolling/idling if fled from.
|
it will return to patrolling/idling if fled from.
|
||||||
immortal: If set, the mob cannot take any damage.
|
immortal: If set, the mob cannot take any damage.
|
||||||
It also has several states,
|
irregular_echoes: list of strings the mob generates at irregular intervals.
|
||||||
is_patrolling - set when the mob is patrolling.
|
desc_alive: the physical description while alive
|
||||||
is_attacking - set when the mob is in combat
|
desc_dead: the physical descripion while dead
|
||||||
is_hunting - set when the mob is pursuing an enemy.
|
send_defeated_to: unique key/alias for location to send defeated enemies to
|
||||||
is_immortal - is currently immortal
|
defeat_msg: message to echo to defeated opponent
|
||||||
is_dead: if set, the Mob is set to immortal, non-patrolling
|
defeat_msg_room: message to echo to room. Accepts %s as the name of the defeated.
|
||||||
and non-aggressive mode. Its description is
|
hit_msg: message to echo when this mob is hit. Accepts %s for the mob's key.
|
||||||
turned into that of a corpse-description.
|
weapon_ineffective_msg: message to echo for useless attacks
|
||||||
Other important properties:
|
death_msg: message to echo to room when this mob dies.
|
||||||
home - the home location should set to someplace inside
|
patrolling_pace: how many seconds per tick, when patrolling
|
||||||
the patrolling area. The mob will use this if it should
|
aggressive_pace: -"- attacking
|
||||||
happen to roam into a room with no exits.
|
hunting_pace: -"- hunting
|
||||||
|
death_pace: -"- returning to life when dead
|
||||||
|
|
||||||
|
field 'home' - the home location should set to someplace inside
|
||||||
|
the patrolling area. The mob will use this if it should
|
||||||
|
happen to roam into a room with no exits.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|
@ -60,10 +107,11 @@ class Mob(tut_objects.TutorialObject):
|
||||||
Called the first time the object is created.
|
Called the first time the object is created.
|
||||||
We set up the base properties and flags here.
|
We set up the base properties and flags here.
|
||||||
"""
|
"""
|
||||||
|
self.cmdsets.add(MobCmdSet, permanent=True)
|
||||||
# Main AI flags. We start in dead mode so we don't have to
|
# Main AI flags. We start in dead mode so we don't have to
|
||||||
# chase the mob around when building.
|
# chase the mob around when building.
|
||||||
self.db.patrolling = False
|
self.db.patrolling = True
|
||||||
self.db.aggressive = False
|
self.db.aggressive = True
|
||||||
self.db.immortal = True
|
self.db.immortal = True
|
||||||
# db-store if it is dead or not
|
# db-store if it is dead or not
|
||||||
self.db.is_dead = True
|
self.db.is_dead = True
|
||||||
|
|
@ -99,10 +147,11 @@ class Mob(tut_objects.TutorialObject):
|
||||||
# text to echo to the defeated foe.
|
# text to echo to the defeated foe.
|
||||||
self.db.defeat_msg = "You fall to the ground."
|
self.db.defeat_msg = "You fall to the ground."
|
||||||
self.db.defeat_msg_room = "%s falls to the ground."
|
self.db.defeat_msg_room = "%s falls to the ground."
|
||||||
self.db.weapon_ineffective_text = "Your weapon just passes through your enemy, causing almost no effect!"
|
self.db.weapon_ineffective_msg = "Your weapon just passes through your enemy, causing almost no effect!"
|
||||||
|
|
||||||
self.db.death_msg = "After the last hit %s evaporates." % self.key
|
self.db.death_msg = "After the last hit %s evaporates." % self.key
|
||||||
self.db.hit_msg = "%s wails, shudders and writhes." % self.key
|
self.db.hit_msg = "%s wails, shudders and writhes." % self.key
|
||||||
|
self.db.irregular_msgs = ["the enemy looks about.", "the enemy changes stance."]
|
||||||
|
|
||||||
self.db.tutorial_info = "This is an object with simple state AI, using a ticker to move."
|
self.db.tutorial_info = "This is an object with simple state AI, using a ticker to move."
|
||||||
|
|
||||||
|
|
@ -127,7 +176,7 @@ class Mob(tut_objects.TutorialObject):
|
||||||
previous ticker subscription so that we can
|
previous ticker subscription so that we can
|
||||||
easily find and stop it before setting a
|
easily find and stop it before setting a
|
||||||
new one. The tickerhandler is persistent so
|
new one. The tickerhandler is persistent so
|
||||||
we need to remmeber this across reloads.
|
we need to remember this across reloads.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
idstring = "tutorial_mob" # this doesn't change
|
idstring = "tutorial_mob" # this doesn't change
|
||||||
|
|
@ -155,7 +204,7 @@ class Mob(tut_objects.TutorialObject):
|
||||||
if obj.has_player and not obj.is_superuser]
|
if obj.has_player and not obj.is_superuser]
|
||||||
return targets[0] if targets else None
|
return targets[0] if targets else None
|
||||||
|
|
||||||
def set_alive(self):
|
def set_alive(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Set the mob to "alive" mode. This effectively
|
Set the mob to "alive" mode. This effectively
|
||||||
resurrects it from the dead state.
|
resurrects it from the dead state.
|
||||||
|
|
@ -232,7 +281,7 @@ class Mob(tut_objects.TutorialObject):
|
||||||
self.ndb.is_hunting = False
|
self.ndb.is_hunting = False
|
||||||
self.ndb.is_attacking = True
|
self.ndb.is_attacking = True
|
||||||
|
|
||||||
def do_patrol(self):
|
def do_patrol(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Called repeatedly during patrolling mode. In this mode, the
|
Called repeatedly during patrolling mode. In this mode, the
|
||||||
mob scans its surroundings and randomly chooses a viable exit.
|
mob scans its surroundings and randomly chooses a viable exit.
|
||||||
|
|
@ -240,6 +289,8 @@ class Mob(tut_objects.TutorialObject):
|
||||||
order to block the mob from moving outside its area while
|
order to block the mob from moving outside its area while
|
||||||
allowing player-controlled characters to move normally.
|
allowing player-controlled characters to move normally.
|
||||||
"""
|
"""
|
||||||
|
if random.random() < 0.01:
|
||||||
|
self.location.msg_contents(random.choice(self.db.irregular_msgs))
|
||||||
if self.db.aggressive:
|
if self.db.aggressive:
|
||||||
# first check if there are any targets in the room.
|
# first check if there are any targets in the room.
|
||||||
target = self._find_target(self.location)
|
target = self._find_target(self.location)
|
||||||
|
|
@ -263,12 +314,14 @@ class Mob(tut_objects.TutorialObject):
|
||||||
# no exits! teleport to home to get away.
|
# no exits! teleport to home to get away.
|
||||||
self.move_to(self.home)
|
self.move_to(self.home)
|
||||||
|
|
||||||
def do_hunting(self):
|
def do_hunting(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Called regularly when in hunting mode. In hunting mode the mob
|
Called regularly when in hunting mode. In hunting mode the mob
|
||||||
scans adjacent rooms for enemies and moves towards them to
|
scans adjacent rooms for enemies and moves towards them to
|
||||||
attack if possible.
|
attack if possible.
|
||||||
"""
|
"""
|
||||||
|
if random.random() < 0.01:
|
||||||
|
self.location.msg_contents(random.choice(self.db.irregular_msgs))
|
||||||
if self.db.aggressive:
|
if self.db.aggressive:
|
||||||
# first check if there are any targets in the room.
|
# first check if there are any targets in the room.
|
||||||
target = self._find_target(self.location)
|
target = self._find_target(self.location)
|
||||||
|
|
@ -293,12 +346,14 @@ class Mob(tut_objects.TutorialObject):
|
||||||
# no exits! teleport to home to get away.
|
# no exits! teleport to home to get away.
|
||||||
self.move_to(self.home)
|
self.move_to(self.home)
|
||||||
|
|
||||||
def do_attacking(self):
|
def do_attacking(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Called regularly when in attacking mode. In attacking mode
|
Called regularly when in attacking mode. In attacking mode
|
||||||
the mob will bring its weapons to bear on any targets
|
the mob will bring its weapons to bear on any targets
|
||||||
in the room.
|
in the room.
|
||||||
"""
|
"""
|
||||||
|
if random.random() < 0.01:
|
||||||
|
self.location.msg_contents(random.choice(self.db.irregular_msgs))
|
||||||
# first make sure we have a target
|
# first make sure we have a target
|
||||||
target = self._find_target(self.location)
|
target = self._find_target(self.location)
|
||||||
if not target:
|
if not target:
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,9 @@ WeaponRack
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from evennia import create_object
|
from evennia import DefaultObject, DefaultExit, Command, CmdSet
|
||||||
from evennia import DefaultObject, DefaultExit, Command, CmdSet, DefaultScript
|
|
||||||
from evennia import utils
|
from evennia import utils
|
||||||
from evennia.utils.spawner import spawn
|
from evennia.utils.spawner import spawn
|
||||||
|
|
||||||
|
|
@ -171,8 +169,8 @@ class CmdClimb(Command):
|
||||||
if not ostring:
|
if not ostring:
|
||||||
ostring = "You climb %s. Having looked around, you climb down again." % self.obj.name
|
ostring = "You climb %s. Having looked around, you climb down again." % self.obj.name
|
||||||
self.caller.msg(ostring)
|
self.caller.msg(ostring)
|
||||||
# store this object to remember what we last climbed
|
# set a tag on the caller to remember that we climbed.
|
||||||
self.caller.db.last_climbed = self.obj
|
self.caller.tags.add("tutorial_climbed_tree")
|
||||||
|
|
||||||
|
|
||||||
class CmdSetClimbable(CmdSet):
|
class CmdSetClimbable(CmdSet):
|
||||||
|
|
@ -611,8 +609,6 @@ class CrumblingWall(TutorialObject, DefaultExit):
|
||||||
# if its location is lit and only traverse it once the Attribute
|
# if its location is lit and only traverse it once the Attribute
|
||||||
# exit_open is set to True.
|
# exit_open is set to True.
|
||||||
self.locks.add("cmd:locattr(is_lit);traverse:objattr(exit_open)")
|
self.locks.add("cmd:locattr(is_lit);traverse:objattr(exit_open)")
|
||||||
|
|
||||||
self.db.tutorial_info = "This is an Exit with a conditional traverse-lock. Try to shift the roots around."
|
|
||||||
# set cmdset
|
# set cmdset
|
||||||
self.cmdset.add(CmdSetCrumblingWall, permanent=True)
|
self.cmdset.add(CmdSetCrumblingWall, permanent=True)
|
||||||
|
|
||||||
|
|
@ -671,7 +667,8 @@ class CrumblingWall(TutorialObject, DefaultExit):
|
||||||
# puzzle not solved yet.
|
# puzzle not solved yet.
|
||||||
string = "The wall is old and covered with roots that here and there have permeated the stone. " \
|
string = "The wall is old and covered with roots that here and there have permeated the stone. " \
|
||||||
"The roots (or whatever they are - some of them are covered in small non-descript flowers) " \
|
"The roots (or whatever they are - some of them are covered in small non-descript flowers) " \
|
||||||
"crisscross the wall, making it hard to clearly see its stony surface.\n"
|
"crisscross the wall, making it hard to clearly see its stony surface. Maybe you could " \
|
||||||
|
"try to {wshift{n or {wmove{n them.\n"
|
||||||
# display the root positions to help with the puzzle
|
# display the root positions to help with the puzzle
|
||||||
for key, pos in self.db.root_pos.items():
|
for key, pos in self.db.root_pos.items():
|
||||||
string += "\n" + self._translate_position(key, pos)
|
string += "\n" + self._translate_position(key, pos)
|
||||||
|
|
@ -851,7 +848,7 @@ class Weapon(TutorialObject):
|
||||||
super(Weapon, self).at_object_creation()
|
super(Weapon, self).at_object_creation()
|
||||||
self.db.hit = 0.4 # hit chance
|
self.db.hit = 0.4 # hit chance
|
||||||
self.db.parry = 0.8 # parry chance
|
self.db.parry = 0.8 # parry chance
|
||||||
self.db.damage = 8.0
|
self.db.damage = 1.0
|
||||||
self.db.magic = False
|
self.db.magic = False
|
||||||
self.cmdset.add_default(CmdSetWeapon, permanent=True)
|
self.cmdset.add_default(CmdSetWeapon, permanent=True)
|
||||||
|
|
||||||
|
|
@ -1007,6 +1004,13 @@ class WeaponRack(TutorialObject):
|
||||||
on it. This will also set a property on the character
|
on it. This will also set a property on the character
|
||||||
to make sure they can't get more than one at a time.
|
to make sure they can't get more than one at a time.
|
||||||
|
|
||||||
|
Attributes to set on this object:
|
||||||
|
available_weapons: list of prototype-keys from
|
||||||
|
WEAPON_PROTOTYPES, the weapons available in this rack.
|
||||||
|
no_more_weapons_msg - error message to return to players
|
||||||
|
who already got one weapon from the rack and tries to
|
||||||
|
grab another one.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def at_object_creation(self):
|
def at_object_creation(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -1016,21 +1020,27 @@ class WeaponRack(TutorialObject):
|
||||||
self.db.rack_id = "weaponrack_1"
|
self.db.rack_id = "weaponrack_1"
|
||||||
# these are prototype names from the prototype
|
# these are prototype names from the prototype
|
||||||
# dictionary above.
|
# dictionary above.
|
||||||
|
self.db.get_weapon_msg = "You pull %s from the rack."
|
||||||
|
self.db.no_more_weapons_msg = "%s has no more to offer you." % self.key
|
||||||
self.db.available_weapons = ["knife", "rusty_dagger",
|
self.db.available_weapons = ["knife", "rusty_dagger",
|
||||||
"sword", "club"]
|
"sword", "club"]
|
||||||
|
|
||||||
def produce_weapon(self, caller):
|
def produce_weapon(self, caller):
|
||||||
"""
|
"""
|
||||||
This will produce a new weapon from the rack,
|
This will produce a new weapon from the rack,
|
||||||
assuming the caller hasn't already gotten one.
|
assuming the caller hasn't already gotten one. When
|
||||||
|
doing so, the caller will get Tagged with the id
|
||||||
|
of this rack, to make sure they cannot keep
|
||||||
|
pulling weapons from it indefinitely.
|
||||||
"""
|
"""
|
||||||
if caller.attributes.get(self.db.rack_id):
|
rack_id = self.db.rack_id
|
||||||
|
if caller.tags.get(rack_id):
|
||||||
caller.msg("%s has no more to offer you." % self.key)
|
caller.msg("%s has no more to offer you." % self.key)
|
||||||
else:
|
else:
|
||||||
prototype = random.choice(self.db.available_weapons)
|
prototype = random.choice(self.db.available_weapons)
|
||||||
# use the spawner to create a new Weapon from the
|
# use the spawner to create a new Weapon from the
|
||||||
# spawner dictionary
|
# spawner dictionary, tag the caller
|
||||||
wpn = spawn(WEAPON_PROTOTYPES[prototype], prototype_parents=WEAPON_PROTOTYPES)
|
wpn = spawn(WEAPON_PROTOTYPES[prototype], prototype_parents=WEAPON_PROTOTYPES)
|
||||||
caller.attributes.add(self.db.rack_id, True)
|
caller.tags.add(rack_id)
|
||||||
wpn.location = caller
|
wpn.location = caller
|
||||||
caller.msg("You grab %s - %s." % (wpn.key, wpn.db.desc))
|
caller.msg(self.db.weapon_msg % wpn.key)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ from evennia import utils, create_object, search_object
|
||||||
from evennia import syscmdkeys, default_cmds
|
from evennia import syscmdkeys, default_cmds
|
||||||
from evennia.contrib.tutorial_world.objects import LightSource, TutorialObject
|
from evennia.contrib.tutorial_world.objects import LightSource, TutorialObject
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
from django.conf import settings
|
||||||
|
_SEARCH_AT_RESULT = utils.object_from_module(settings.SEARCH_AT_RESULT)
|
||||||
|
|
||||||
#------------------------------------------------------------
|
#------------------------------------------------------------
|
||||||
#
|
#
|
||||||
|
|
@ -68,15 +73,139 @@ class CmdTutorial(Command):
|
||||||
caller.msg("{RSorry, there is no tutorial help available here.{n")
|
caller.msg("{RSorry, there is no tutorial help available here.{n")
|
||||||
|
|
||||||
|
|
||||||
|
# for the @detail command we inherit from MuxCommand, since
|
||||||
|
# we want to make use of MuxCommand's pre-parsing of '=' in the
|
||||||
|
# argument.
|
||||||
|
class CmdTutorialSetDetail(default_cmds.MuxCommand):
|
||||||
|
"""
|
||||||
|
sets a detail on a room
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@detail <key> = <description>
|
||||||
|
@detail <key>;<alias>;... = description
|
||||||
|
|
||||||
|
Example:
|
||||||
|
@detail walls = The walls are covered in ...
|
||||||
|
@detail castle;ruin;tower = The distant ruin ...
|
||||||
|
|
||||||
|
This sets a "detail" on the object this command is defined on
|
||||||
|
(TutorialRoom for this tutorial). This detail can be accessed with
|
||||||
|
the TutorialRoomLook command sitting on TutorialRoom objects (details
|
||||||
|
are set as a simple dictionary on the room). This is a Builder command.
|
||||||
|
|
||||||
|
We custom parse the key for the ;-separator in order to create
|
||||||
|
multiple aliases to the detail all at once.
|
||||||
|
"""
|
||||||
|
key = "@detail"
|
||||||
|
locks = "cmd:perm(Builders)"
|
||||||
|
help_category = "TutorialWorld"
|
||||||
|
|
||||||
|
def func(self):
|
||||||
|
"""
|
||||||
|
All this does is to check if the object has
|
||||||
|
the set_detail method and uses it.
|
||||||
|
"""
|
||||||
|
if not self.args or not self.rhs:
|
||||||
|
self.caller.msg("Usage: @detail key = description")
|
||||||
|
return
|
||||||
|
if not hasattr(self.obj, "set_detail"):
|
||||||
|
self.caller.msg("Details cannot be set on %s." % self.obj)
|
||||||
|
return
|
||||||
|
for key in self.args.split(";"):
|
||||||
|
# loop over all aliases, if any (if not, this will just be
|
||||||
|
# the one key to loop over)
|
||||||
|
self.obj.set_detail(key, self.rhs)
|
||||||
|
self.caller.msg("Detail set: '%s': '%s'" % (self.lhs, self.rhs))
|
||||||
|
|
||||||
|
|
||||||
|
class CmdTutorialLook(default_cmds.CmdLook):
|
||||||
|
"""
|
||||||
|
looks at the room and on details
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
look <obj>
|
||||||
|
look <room detail>
|
||||||
|
look *<player>
|
||||||
|
|
||||||
|
Observes your location, details at your location or objects
|
||||||
|
in your vicinity.
|
||||||
|
|
||||||
|
Tutorial: This is a child of the default Look command, that also
|
||||||
|
allows us to look at "details" in the room. These details are
|
||||||
|
things to examine and offers some extra description without
|
||||||
|
actually having to be actual database objects. It uses the
|
||||||
|
return_detail() hook on TutorialRooms for this.
|
||||||
|
"""
|
||||||
|
# we don't need to specify key/locks etc, this is already
|
||||||
|
# set by the parent.
|
||||||
|
help_category = "TutorialWorld"
|
||||||
|
|
||||||
|
def func(self):
|
||||||
|
"""
|
||||||
|
Handle the looking. This is a copy of the default look
|
||||||
|
code except for adding in the details.
|
||||||
|
"""
|
||||||
|
caller = self.caller
|
||||||
|
args = self.args
|
||||||
|
print "tutorial look"
|
||||||
|
if args:
|
||||||
|
# we use quiet=True to turn off automatic error reporting.
|
||||||
|
# This tells search that we want to handle error messages
|
||||||
|
# ourself. This also means the search function will always
|
||||||
|
# return a list (with 0, 1 or more elements) rather than
|
||||||
|
# result/None.
|
||||||
|
looking_at_obj = caller.search(args, use_nicks=True, quiet=True)
|
||||||
|
if len(looking_at_obj) != 1:
|
||||||
|
# no target found or more than one target found (multimatch)
|
||||||
|
# look for a detail that may match
|
||||||
|
detail = self.obj.return_detail(args)
|
||||||
|
print "look detail:", detail, self.obj, self.obj.db.details
|
||||||
|
if detail:
|
||||||
|
self.caller.msg(detail)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# no detail found, delegate our result to the normal
|
||||||
|
# error message handler.
|
||||||
|
_SEARCH_AT_RESULT(caller, args, looking_at_obj)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# we found a match, extract it from the list and carry on
|
||||||
|
# normally with the look handling.
|
||||||
|
looking_at_obj = looking_at_obj[0]
|
||||||
|
|
||||||
|
else:
|
||||||
|
looking_at_obj = caller.location
|
||||||
|
if not looking_at_obj:
|
||||||
|
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 a player 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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TutorialRoomCmdSet(CmdSet):
|
class TutorialRoomCmdSet(CmdSet):
|
||||||
"""
|
"""
|
||||||
Implements the simple tutorial cmdset
|
Implements the simple tutorial cmdset. This will overload the look
|
||||||
|
command in the default CharacterCmdSet since it has a higher
|
||||||
|
priority (ChracterCmdSet has prio 0)
|
||||||
"""
|
"""
|
||||||
key = "tutorial_cmdset"
|
key = "tutorial_cmdset"
|
||||||
|
priority = 1
|
||||||
|
|
||||||
def at_cmdset_creation(self):
|
def at_cmdset_creation(self):
|
||||||
"add the tutorial cmd"
|
"add the tutorial-room commands"
|
||||||
self.add(CmdTutorial())
|
self.add(CmdTutorial())
|
||||||
|
self.add(CmdTutorialSetDetail())
|
||||||
|
self.add(CmdTutorialLook())
|
||||||
|
|
||||||
|
|
||||||
class TutorialRoom(DefaultRoom):
|
class TutorialRoom(DefaultRoom):
|
||||||
|
|
@ -95,6 +224,11 @@ class TutorialRoom(DefaultRoom):
|
||||||
the room about it by trying to call a hook on them. The Mob object
|
the room about it by trying to call a hook on them. The Mob object
|
||||||
uses this to cheaply get notified of enemies without having
|
uses this to cheaply get notified of enemies without having
|
||||||
to constantly scan for them.
|
to constantly scan for them.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
new_arrival (Object): the object that just entered this room.
|
||||||
|
source_location (Object): the previous location of new_arrival.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if new_arrival.has_player and not new_arrival.is_superuser:
|
if new_arrival.has_player and not new_arrival.is_superuser:
|
||||||
# this is a character
|
# this is a character
|
||||||
|
|
@ -102,6 +236,36 @@ class TutorialRoom(DefaultRoom):
|
||||||
if hasattr(obj, "at_new_arrival"):
|
if hasattr(obj, "at_new_arrival"):
|
||||||
obj.at_new_arrival(new_arrival)
|
obj.at_new_arrival(new_arrival)
|
||||||
|
|
||||||
|
def return_detail(self, detailkey):
|
||||||
|
"""
|
||||||
|
This looks for an Attribute "obj_details" and possibly
|
||||||
|
returns the value of it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
detailkey (str): The detail being looked at
|
||||||
|
|
||||||
|
"""
|
||||||
|
details = self.db.details
|
||||||
|
if details:
|
||||||
|
return details.get(detailkey, None)
|
||||||
|
|
||||||
|
def set_detail(self, detailkey, description):
|
||||||
|
"""
|
||||||
|
This sets a new detail, using an Attribute "details".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
detailkey (str): The detail identifier to add (for
|
||||||
|
aliases you need to add multiple keys to the
|
||||||
|
same description).
|
||||||
|
description (str): The text to return when looking
|
||||||
|
at the given detailkey.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if self.db.details:
|
||||||
|
self.db.details[detailkey] = description
|
||||||
|
else:
|
||||||
|
self.db.details = {detailkey: description}
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
"Can be called by the tutorial runner."
|
"Can be called by the tutorial runner."
|
||||||
pass
|
pass
|
||||||
|
|
@ -155,11 +319,13 @@ class WeatherRoom(TutorialRoom):
|
||||||
self.db.tutorial_info = \
|
self.db.tutorial_info = \
|
||||||
"This room has a Script running that has it echo a weather-related message at irregular intervals."
|
"This room has a Script running that has it echo a weather-related message at irregular intervals."
|
||||||
|
|
||||||
def update_weather(self):
|
def update_weather(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Called by the tickerhandler at regular intervals. Even so, we
|
Called by the tickerhandler at regular intervals. Even so, we
|
||||||
only update 20% of the time, picking a random weather message
|
only update 20% of the time, picking a random weather message
|
||||||
when we do.
|
when we do. The tickerhandler requires that this hook accepts
|
||||||
|
any arguments and keyword arguments (hence the *args, **kwargs
|
||||||
|
even though we don't actually use them in this example)
|
||||||
"""
|
"""
|
||||||
if random.random() < 0.2:
|
if random.random() < 0.2:
|
||||||
# only update 20 % of the time
|
# only update 20 % of the time
|
||||||
|
|
@ -168,7 +334,7 @@ class WeatherRoom(TutorialRoom):
|
||||||
|
|
||||||
#------------------------------------------------------------------------------
|
#------------------------------------------------------------------------------
|
||||||
#
|
#
|
||||||
# Dark Room - a scripted room
|
# Dark Room - a room with states
|
||||||
#
|
#
|
||||||
# This room limits the movemenets of its denizens unless they carry an active
|
# This room limits the movemenets of its denizens unless they carry an active
|
||||||
# LightSource object (LightSource is defined in
|
# LightSource object (LightSource is defined in
|
||||||
|
|
@ -395,7 +561,10 @@ class TeleportRoom(TutorialRoom):
|
||||||
Important attributes (set at creation):
|
Important attributes (set at creation):
|
||||||
puzzle_key - which attr to look for on character
|
puzzle_key - which attr to look for on character
|
||||||
puzzle_value - what char.db.puzzle_key must be set to
|
puzzle_value - what char.db.puzzle_key must be set to
|
||||||
teleport_to - where to teleport to in case of failure to match
|
success_teleport_to - where to teleport in case if success
|
||||||
|
success_teleport_msg - message to echo while teleporting to success
|
||||||
|
failure_teleport_to - where to teleport to in case of failure
|
||||||
|
failure_teleport_msg - message to echo while teleporting to failure
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def at_object_creation(self):
|
def at_object_creation(self):
|
||||||
|
|
@ -405,8 +574,10 @@ class TeleportRoom(TutorialRoom):
|
||||||
self.db.puzzle_value = 1
|
self.db.puzzle_value = 1
|
||||||
# target of successful teleportation. Can be a dbref or a
|
# target of successful teleportation. Can be a dbref or a
|
||||||
# unique room name.
|
# unique room name.
|
||||||
|
self.db.success_teleport_msg = "You are successful!"
|
||||||
self.db.success_teleport_to = "treasure room"
|
self.db.success_teleport_to = "treasure room"
|
||||||
# the target of the failure teleportation.
|
# the target of the failure teleportation.
|
||||||
|
self.db.failure_teleport_msg = "You fail!"
|
||||||
self.db.failure_teleport_to = "dark cell"
|
self.db.failure_teleport_to = "dark cell"
|
||||||
|
|
||||||
def at_object_receive(self, character, source_location):
|
def at_object_receive(self, character, source_location):
|
||||||
|
|
@ -417,14 +588,10 @@ class TeleportRoom(TutorialRoom):
|
||||||
if not character.has_player:
|
if not character.has_player:
|
||||||
# only act on player characters.
|
# only act on player characters.
|
||||||
return
|
return
|
||||||
#print character.db.puzzle_clue, self.db.puzzle_value
|
# determine if the puzzle is a success or not
|
||||||
if character.db.puzzle_clue != self.db.puzzle_value:
|
is_success = character.db.puzzle_clue == self.db.puzzle_value
|
||||||
# we didn't pass the puzzle. See if we can teleport.
|
teleport_to = self.db.success_teleport_to if is_success else self.db.failure_teleport_to
|
||||||
teleport_to = self.db.failure_teleport_to # this is a room name
|
# note that this returns a list
|
||||||
else:
|
|
||||||
# passed the puzzle
|
|
||||||
teleport_to = self.db.success_teleport_to # this is a room name
|
|
||||||
|
|
||||||
results = search_object(teleport_to)
|
results = search_object(teleport_to)
|
||||||
if not results or len(results) > 1:
|
if not results or len(results) > 1:
|
||||||
# we cannot move anywhere since no valid target was found.
|
# we cannot move anywhere since no valid target was found.
|
||||||
|
|
@ -434,8 +601,11 @@ class TeleportRoom(TutorialRoom):
|
||||||
# superusers don't get teleported
|
# superusers don't get teleported
|
||||||
character.msg("Superuser block: You would have been teleported to %s." % results[0])
|
character.msg("Superuser block: You would have been teleported to %s." % results[0])
|
||||||
return
|
return
|
||||||
# the teleporter room's desc should give the 'teleporting message'.
|
# perform the teleport
|
||||||
character.execute_cmd("look")
|
if is_success:
|
||||||
|
character.msg(self.db.success_teleport_msg)
|
||||||
|
else:
|
||||||
|
character.msg(self.db.failure_teleport_msg)
|
||||||
# teleport quietly to the new place
|
# teleport quietly to the new place
|
||||||
character.move_to(results[0], quiet=True)
|
character.move_to(results[0], quiet=True)
|
||||||
|
|
||||||
|
|
@ -468,7 +638,7 @@ class CmdEast(Command):
|
||||||
when exiting east.
|
when exiting east.
|
||||||
- west_exit: a unique name or dbref to the room to go to
|
- west_exit: a unique name or dbref to the room to go to
|
||||||
when exiting west.
|
when exiting west.
|
||||||
The room must also have the following property:
|
The room must also have the following Attributes
|
||||||
- tutorial_bridge_posistion: the current position on
|
- tutorial_bridge_posistion: the current position on
|
||||||
on the bridge, 0 - 4.
|
on the bridge, 0 - 4.
|
||||||
|
|
||||||
|
|
@ -687,13 +857,8 @@ class BridgeRoom(WeatherRoom):
|
||||||
self.db.fall_exit = "cliffledge"
|
self.db.fall_exit = "cliffledge"
|
||||||
# add the cmdset on the room.
|
# add the cmdset on the room.
|
||||||
self.cmdset.add_default(BridgeCmdSet)
|
self.cmdset.add_default(BridgeCmdSet)
|
||||||
# information for those using the tutorial command
|
|
||||||
self.db.tutorial_info = \
|
|
||||||
"The bridge seems large but is actually only a " \
|
|
||||||
"single room that assigns custom west/east commands " \
|
|
||||||
"and a counter to determine how far across you are."
|
|
||||||
|
|
||||||
def update_weather(self):
|
def update_weather(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
This is called at irregular intervals and makes the passage
|
This is called at irregular intervals and makes the passage
|
||||||
over the bridge a little more interesting.
|
over the bridge a little more interesting.
|
||||||
|
|
@ -801,7 +966,7 @@ class OutroRoom(TutorialRoom):
|
||||||
"""
|
"""
|
||||||
Called when the room is first created.
|
Called when the room is first created.
|
||||||
"""
|
"""
|
||||||
super(IntroRoom, self).at_object_creation()
|
super(OutroRoom, self).at_object_creation()
|
||||||
self.db_tutorial_info = "The last room of the tutorial. " \
|
self.db_tutorial_info = "The last room of the tutorial. " \
|
||||||
"This cleans up all temporary Attributes " \
|
"This cleans up all temporary Attributes " \
|
||||||
"the tutorial may have assigned to the "\
|
"the tutorial may have assigned to the "\
|
||||||
|
|
|
||||||
|
|
@ -422,6 +422,27 @@ def attr_ne(accessing_obj, accessed_obj, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
return attr(accessing_obj, accessed_obj, *args, **{'compare': 'ne'})
|
return attr(accessing_obj, accessed_obj, *args, **{'compare': 'ne'})
|
||||||
|
|
||||||
|
def tag(accessing_obj, accessed_obj, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Usage:
|
||||||
|
tag(tagkey)
|
||||||
|
tag(tagkey, category)
|
||||||
|
|
||||||
|
Only true if accessing_obj has the specified tag and optional
|
||||||
|
category
|
||||||
|
"""
|
||||||
|
return accessing_obj.tags.get(*args)
|
||||||
|
|
||||||
|
def objtag(accessing_obj, accessed_obj, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Usage:
|
||||||
|
objtag(tagkey)
|
||||||
|
objtag(tagkey, category)
|
||||||
|
|
||||||
|
Only true if accessed_obj has the specified tag and optional
|
||||||
|
category.
|
||||||
|
"""
|
||||||
|
return accessed_obj.tags.get(*args)
|
||||||
|
|
||||||
def inside(accessing_obj, accessed_obj, *args, **kwargs):
|
def inside(accessing_obj, accessed_obj, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -268,7 +268,8 @@ class TickerHandler(object):
|
||||||
the same time interval
|
the same time interval
|
||||||
hook_key (str, optional): The name of the hook method
|
hook_key (str, optional): The name of the hook method
|
||||||
on `obj` to call every `interval` seconds. Defaults to
|
on `obj` to call every `interval` seconds. Defaults to
|
||||||
`at_tick()`.
|
`at_tick(*args, **kwargs`. All hook methods must
|
||||||
|
always accept *args, **kwargs.
|
||||||
args, kwargs (optional): These will be passed into the
|
args, kwargs (optional): These will be passed into the
|
||||||
method given by `hook_key` every time it is called.
|
method given by `hook_key` every time it is called.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import textwrap
|
||||||
import datetime
|
import datetime
|
||||||
import random
|
import random
|
||||||
import traceback
|
import traceback
|
||||||
from subprocess import check_output
|
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from inspect import ismodule, trace
|
from inspect import ismodule, trace
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
@ -967,7 +966,8 @@ def class_from_module(path, defaultpaths=None):
|
||||||
err += "."
|
err += "."
|
||||||
raise ImportError(err)
|
raise ImportError(err)
|
||||||
return cls
|
return cls
|
||||||
|
# alias
|
||||||
|
object_from_module = class_from_module
|
||||||
|
|
||||||
def init_new_player(player):
|
def init_new_player(player):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue