Merge conflict

This commit is contained in:
Henddher Pedroza 2018-10-06 06:56:24 -05:00
commit c7907cbf6c
124 changed files with 21965 additions and 3894 deletions

View file

@ -16,10 +16,12 @@ things you want from here into your game folder and change them there.
## Contrib modules
* Barter system (Griatch 2012) - A safe and effective barter-system
for any game. Allows safe trading of any godds (including coin)
for any game. Allows safe trading of any goods (including coin).
* Building menu (vincent-lg 2018) - An @edit command for modifying
objects using a generated menu. Customizable for different games.
* CharGen (Griatch 2011) - A simple Character creator for OOC mode.
Meant as a starting point for a more fleshed-out system.
* Clothing (BattleJenkins 2017) - A layered clothing system with
* Clothing (FlutterSprite 2017) - A layered clothing system with
slots for different types of garments auto-showing in description.
* Color-markups (Griatch, 2017) - Alternative in-game color markups.
* Custom gametime (Griatch, vlgeoff 2017) - Implements Evennia's
@ -29,11 +31,15 @@ things you want from here into your game folder and change them there.
that requires an email to login rather then just name+password.
* Extended Room (Griatch 2012) - An expanded Room typeclass with
multiple descriptions for time and season as well as details.
* Field Fill (FlutterSprite 2018) - A simple system for creating an
EvMenu that presents a player with a highly customizable fillable
form
* GenderSub (Griatch 2015) - Simple example (only) of storing gender
on a character and access it in an emote with a custom marker.
* Health Bar (Tim Ashley Jenkins 2017) - Tool to create colorful bars/meters.
* Mail (grungies1138 2016) - An in-game mail system for communication.
* Menu login (Griatch 2011) - A login system using menus asking
for name/password rather than giving them as one command
for name/password rather than giving them as one command.
* Map Builder (CloudKeeper 2016) - Build a game area based on a 2D
"graphical" unicode map. Supports assymmetric exits.
* Menu Login (Vincent-lg 2016) - Alternate login system using EvMenu.
@ -45,13 +51,18 @@ things you want from here into your game folder and change them there.
speaking unfamiliar languages. Also obfuscates whispers.
* RPSystem (Griatch 2015) - Full director-style emoting system
replacing names with sdescs/recogs. Supports wearing masks.
* Security/Auditing (Johhny 2018) - Log server input/output for debug/security.
* Simple Door - Example of an exit that can be opened and closed.
* Slow exit (Griatch 2014) - Custom Exit class that takes different
time to pass depending on if you are walking/running etc.
* Talking NPC (Griatch 2011) - A talking NPC object that offers a
menu-driven conversation tree.
* Turnbattle (BattleJenkins 2017) - A turn-based combat engine meant
as a start to build from. Has attack/disengage and turn timeouts.
* Tree Select (FlutterSprite 2017) - A simple system for creating a
branching EvMenu with selection options sourced from a single
multi-line string.
* Turnbattle (Tim Ashley Jenkins 2017) - This is a framework for a turn-based
combat system with different levels of complexity, including versions with
equipment and magic as well as ranged combat.
* Wilderness (titeuf87 2017) - Make infinitely large wilderness areas
with dynamically created locations.
* UnixCommand (Vincent Le Geoff 2017) - Add commands with UNIX-style syntax.
@ -59,9 +70,12 @@ things you want from here into your game folder and change them there.
## Contrib packages
* EGI_Client (gtaylor 2016) - Client for reporting game status
to the Evennia game index (games.evennia.com)
to the Evennia game index (games.evennia.com).
* In-game Python (Vincent Le Goff 2017) - Allow trusted builders to script
objects and events using Python from in-game.
* Turnbattle (FlutterSprite 2017) - A turn-based combat engine meant
as a start to build from. Has attack/disengage and turn timeouts,
and includes optional expansions for equipment and combat movement.
* Tutorial examples (Griatch 2011, 2015) - A folder of basic
example objects, commands and scripts.
* Tutorial world (Griatch 2011, 2015) - A folder containing the

File diff suppressed because it is too large Load diff

View file

@ -197,7 +197,7 @@ class CmdUnconnectedCreate(MuxCommand):
session.msg(string)
return
if not re.findall(r"^[\w. @+\-']+$", password) or not (3 < len(password)):
string = "\n\r Password should be longer than 3 characers. Letters, spaces, digits and @/./+/-/_/' only." \
string = "\n\r Password should be longer than 3 characters. Letters, spaces, digits and @/./+/-/_/' only." \
"\nFor best security, make it longer than 8 characters. You can also use a phrase of" \
"\nmany words if you enclose the password in double quotes."
session.msg(string)

View file

@ -213,37 +213,39 @@ class ExtendedRoom(DefaultRoom):
return detail
return None
def return_appearance(self, looker):
def return_appearance(self, looker, **kwargs):
"""
This is called when e.g. the look command wants to retrieve
the description of this object.
Args:
looker (Object): The object looking at us.
**kwargs (dict): Arbitrary, optional arguments for users
overriding the call (unused by default).
Returns:
description (str): Our description.
"""
update = False
# 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(ExtendedRoom, self).return_appearance(looker, **kwargs)
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.
if curr_season == 'spring':
new_raw_desc = self.db.spring_desc
elif curr_season == 'summer':
new_raw_desc = self.db.summer_desc
elif curr_season == 'autumn':
new_raw_desc = self.db.autumn_desc
else:
new_raw_desc = self.db.winter_desc
new_raw_desc = self.attributes.get("%s_desc" % curr_season)
if new_raw_desc:
raw_desc = new_raw_desc
else:
@ -252,19 +254,15 @@ class ExtendedRoom(DefaultRoom):
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)
# run the normal return_appearance method, now that desc is updated.
return super(ExtendedRoom, self).return_appearance(looker)
# Custom Look command supporting Room details. Add this to
@ -369,6 +367,7 @@ class CmdExtendedDesc(default_cmds.CmdDesc):
"""
aliases = ["describe", "detail"]
switch_options = () # Inherits from default_cmds.CmdDesc, but unused here
def reset_times(self, obj):
"""By deleteting the caches we force a re-load."""

View file

@ -0,0 +1,667 @@
"""
Easy fillable form
Contrib - Tim Ashley Jenkins 2018
This module contains a function that calls an easily customizable EvMenu - this
menu presents the player with a fillable form, with fields that can be filled
out in any order. Each field's value can be verified, with the function
allowing easy checks for text and integer input, minimum and maximum values /
character lengths, or can even be verified by a custom function. Once the form
is submitted, the form's data is submitted as a dictionary to any callable of
your choice.
The function that initializes the fillable form menu is fairly simple, and
includes the caller, the template for the form, and the callback(caller, result) to which the form
data will be sent to upon submission.
init_fill_field(formtemplate, caller, formcallback)
Form templates are defined as a list of dictionaries - each dictionary
represents a field in the form, and contains the data for the field's name and
behavior. For example, this basic form template will allow a player to fill out
a brief character profile:
PROFILE_TEMPLATE = [
{"fieldname":"Name", "fieldtype":"text"},
{"fieldname":"Age", "fieldtype":"number"},
{"fieldname":"History", "fieldtype":"text"},
]
This will present the player with an EvMenu showing this basic form:
Name:
Age:
History:
While in this menu, the player can assign a new value to any field with the
syntax <field> = <new value>, like so:
> name = Ashley
Field 'Name' set to: Ashley
Typing 'look' by itself will show the form and its current values.
> look
Name: Ashley
Age:
History:
Number fields require an integer input, and will reject any text that can't
be converted into an integer.
> age = youthful
Field 'Age' requires a number.
> age = 31
Field 'Age' set to: 31
Form data is presented as an EvTable, so text of any length will wrap cleanly.
> history = EVERY MORNING I WAKE UP AND OPEN PALM SLAM[...]
Field 'History' set to: EVERY MORNING I WAKE UP AND[...]
> look
Name: Ashley
Age: 31
History: EVERY MORNING I WAKE UP AND OPEN PALM SLAM A VHS INTO THE SLOT.
IT'S CHRONICLES OF RIDDICK AND RIGHT THEN AND THERE I START DOING
THE MOVES ALONGSIDE WITH THE MAIN CHARACTER, RIDDICK. I DO EVERY
MOVE AND I DO EVERY MOVE HARD.
When the player types 'submit' (or your specified submit command), the menu
quits and the form's data is passed to your specified function as a dictionary,
like so:
formdata = {"Name":"Ashley", "Age":31, "History":"EVERY MORNING I[...]"}
You can do whatever you like with this data in your function - forms can be used
to set data on a character, to help builders create objects, or for players to
craft items or perform other complicated actions with many variables involved.
The data that your form will accept can also be specified in your form template -
let's say, for example, that you won't accept ages under 18 or over 100. You can
do this by specifying "min" and "max" values in your field's dictionary:
PROFILE_TEMPLATE = [
{"fieldname":"Name", "fieldtype":"text"},
{"fieldname":"Age", "fieldtype":"number", "min":18, "max":100},
{"fieldname":"History", "fieldtype":"text"}
]
Now if the player tries to enter a value out of range, the form will not acept the
given value.
> age = 10
Field 'Age' reqiures a minimum value of 18.
> age = 900
Field 'Age' has a maximum value of 100.
Setting 'min' and 'max' for a text field will instead act as a minimum or
maximum character length for the player's input.
There are lots of ways to present the form to the player - fields can have default
values or show a custom message in place of a blank value, and player input can be
verified by a custom function, allowing for a great deal of flexibility. There
is also an option for 'bool' fields, which accept only a True / False input and
can be customized to represent the choice to the player however you like (E.G.
Yes/No, On/Off, Enabled/Disabled, etc.)
This module contains a simple example form that demonstrates all of the included
functionality - a command that allows a player to compose a message to another
online character and have it send after a custom delay. You can test it by
importing this module in your game's default_cmdsets.py module and adding
CmdTestMenu to your default character's command set.
FIELD TEMPLATE KEYS:
Required:
fieldname (str): Name of the field, as presented to the player.
fieldtype (str): Type of value required: 'text', 'number', or 'bool'.
Optional:
max (int): Maximum character length (if text) or value (if number).
min (int): Minimum charater length (if text) or value (if number).
truestr (str): String for a 'True' value in a bool field.
(E.G. 'On', 'Enabled', 'Yes')
falsestr (str): String for a 'False' value in a bool field.
(E.G. 'Off', 'Disabled', 'No')
default (str): Initial value (blank if not given).
blankmsg (str): Message to show in place of value when field is blank.
cantclear (bool): Field can't be cleared if True.
required (bool): If True, form cannot be submitted while field is blank.
verifyfunc (callable): Name of a callable used to verify input - takes
(caller, value) as arguments. If the function returns True,
the player's input is considered valid - if it returns False,
the input is rejected. Any other value returned will act as
the field's new value, replacing the player's input. This
allows for values that aren't strings or integers (such as
object dbrefs). For boolean fields, return '0' or '1' to set
the field to False or True.
"""
from evennia.utils import evmenu, evtable, delay, list_to_string, logger
from evennia import Command
from evennia.server.sessionhandler import SESSIONS
class FieldEvMenu(evmenu.EvMenu):
"""
Custom EvMenu type with its own node formatter - removes extraneous lines
"""
def node_formatter(self, nodetext, optionstext):
"""
Formats the entirety of the node.
Args:
nodetext (str): The node text as returned by `self.nodetext_formatter`.
optionstext (str): The options display as returned by `self.options_formatter`.
caller (Object, Account or None, optional): The caller of the node.
Returns:
node (str): The formatted node to display.
"""
# Only return node text, no options or separators
return nodetext
def init_fill_field(formtemplate, caller, formcallback, pretext="", posttext="",
submitcmd="submit", borderstyle="cells", formhelptext=None,
persistent=False, initial_formdata=None):
"""
Initializes a menu presenting a player with a fillable form - once the form
is submitted, the data will be passed as a dictionary to your chosen
function.
Args:
formtemplate (list of dicts): The template for the form's fields.
caller (obj): Player who will be filling out the form.
formcallback (callable): Function to pass the completed form's data to.
Options:
pretext (str): Text to put before the form in the menu.
posttext (str): Text to put after the form in the menu.
submitcmd (str): Command used to submit the form.
borderstyle (str): Form's EvTable border style.
formhelptext (str): Help text for the form menu (or default is provided).
persistent (bool): Whether to make the EvMenu persistent across reboots.
initial_formdata (dict): Initial data for the form - a blank form with
defaults specified in the template will be generated otherwise.
In the case of a form used to edit properties on an object or a
similar application, you may want to generate the initial form
data dynamically before calling init_fill_field.
"""
# Initialize form data from the template if none provided
formdata = form_template_to_dict(formtemplate)
if initial_formdata:
formdata = initial_formdata
# Provide default help text if none given
if formhelptext is None:
formhelptext = (
"Available commands:|/"
"|w<field> = <new value>:|n Set given field to new value, replacing the old value|/"
"|wclear <field>:|n Clear the value in the given field, making it blank|/"
"|wlook|n: Show the form's current values|/"
"|whelp|n: Display this help screen|/"
"|wquit|n: Quit the form menu without submitting|/"
"|w%s|n: Submit this form and quit the menu" % submitcmd)
# Pass kwargs to store data needed in the menu
kwargs = {
"formdata": formdata,
"formtemplate": formtemplate,
"formcallback": formcallback,
"pretext": pretext,
"posttext": posttext,
"submitcmd": submitcmd,
"borderstyle": borderstyle,
"formhelptext": formhelptext
}
# Initialize menu of selections
FieldEvMenu(caller, "evennia.contrib.fieldfill", startnode="menunode_fieldfill",
auto_look=False, persistent=persistent, **kwargs)
def menunode_fieldfill(caller, raw_string, **kwargs):
"""
This is an EvMenu node, which calls itself over and over in order to
allow a player to enter values into a fillable form. When the form is
submitted, the form data is passed to a callback as a dictionary.
"""
# Retrieve menu info - taken from ndb if not persistent or db if persistent
if not caller.db._menutree:
formdata = caller.ndb._menutree.formdata
formtemplate = caller.ndb._menutree.formtemplate
formcallback = caller.ndb._menutree.formcallback
pretext = caller.ndb._menutree.pretext
posttext = caller.ndb._menutree.posttext
submitcmd = caller.ndb._menutree.submitcmd
borderstyle = caller.ndb._menutree.borderstyle
formhelptext = caller.ndb._menutree.formhelptext
else:
formdata = caller.db._menutree.formdata
formtemplate = caller.db._menutree.formtemplate
formcallback = caller.db._menutree.formcallback
pretext = caller.db._menutree.pretext
posttext = caller.db._menutree.posttext
submitcmd = caller.db._menutree.submitcmd
borderstyle = caller.db._menutree.borderstyle
formhelptext = caller.db._menutree.formhelptext
# Syntax error
syntax_err = "Syntax: <field> = <new value>|/Or: clear <field>, help, look, quit|/'%s' to submit form" % submitcmd
# Display current form data
text = (display_formdata(formtemplate, formdata, pretext=pretext,
posttext=posttext, borderstyle=borderstyle), formhelptext)
options = ({"key": "_default",
"goto": "menunode_fieldfill"})
if raw_string:
# Test for given 'submit' command
if raw_string.lower().strip() == submitcmd:
# Test to see if any blank fields are required
blank_and_required = []
for field in formtemplate:
if "required" in field.keys():
# If field is required but current form data for field is blank
if field["required"] is True and formdata[field["fieldname"]] is None:
# Add to blank and required fields
blank_and_required.append(field["fieldname"])
if len(blank_and_required) > 0:
# List the required fields left empty to the player
caller.msg("The following blank fields require a value: %s" % list_to_string(blank_and_required))
text = (None, formhelptext)
return text, options
# If everything checks out, pass form data to the callback and end the menu!
try:
formcallback(caller, formdata)
except Exception:
logger.log_trace("Error in fillable form callback.")
return None, None
# Test for 'look' command
if raw_string.lower().strip() == "look" or raw_string.lower().strip() == "l":
return text, options
# Test for 'clear' command
cleartest = raw_string.lower().strip().split(" ", 1)
if cleartest[0].lower() == "clear":
text = (None, formhelptext)
if len(cleartest) < 2:
caller.msg(syntax_err)
return text, options
matched_field = None
for key in formdata.keys():
if cleartest[1].lower() in key.lower():
matched_field = key
if not matched_field:
caller.msg("Field '%s' does not exist!" % cleartest[1])
text = (None, formhelptext)
return text, options
# Test to see if field can be cleared
for field in formtemplate:
if field["fieldname"] == matched_field and "cantclear" in field.keys():
if field["cantclear"] is True:
caller.msg("Field '%s' can't be cleared!" % matched_field)
text = (None, formhelptext)
return text, options
# Clear the field
formdata.update({matched_field: None})
caller.ndb._menutree.formdata = formdata
caller.msg("Field '%s' cleared." % matched_field)
return text, options
if "=" not in raw_string:
text = (None, formhelptext)
caller.msg(syntax_err)
return text, options
# Extract field name and new field value
entry = raw_string.split("=", 1)
fieldname = entry[0].strip()
newvalue = entry[1].strip()
# Syntax error if field name is too short or blank
if len(fieldname) < 1:
caller.msg(syntax_err)
text = (None, formhelptext)
return text, options
# Attempt to match field name to field in form data
matched_field = None
for key in formdata.keys():
if fieldname.lower() in key.lower():
matched_field = key
# No matched field
if matched_field is None:
caller.msg("Field '%s' does not exist!" % fieldname)
text = (None, formhelptext)
return text, options
# Set new field value if match
# Get data from template
fieldtype = None
max_value = None
min_value = None
truestr = "True"
falsestr = "False"
verifyfunc = None
for field in formtemplate:
if field["fieldname"] == matched_field:
fieldtype = field["fieldtype"]
if "max" in field.keys():
max_value = field["max"]
if "min" in field.keys():
min_value = field["min"]
if "truestr" in field.keys():
truestr = field["truestr"]
if "falsestr" in field.keys():
falsestr = field["falsestr"]
if "verifyfunc" in field.keys():
verifyfunc = field["verifyfunc"]
# Field type text verification
if fieldtype == "text":
# Test for max/min
if max_value is not None:
if len(newvalue) > max_value:
caller.msg("Field '%s' has a maximum length of %i characters." % (matched_field, max_value))
text = (None, formhelptext)
return text, options
if min_value is not None:
if len(newvalue) < min_value:
caller.msg("Field '%s' reqiures a minimum length of %i characters." % (matched_field, min_value))
text = (None, formhelptext)
return text, options
# Field type number verification
if fieldtype == "number":
try:
newvalue = int(newvalue)
except:
caller.msg("Field '%s' requires a number." % matched_field)
text = (None, formhelptext)
return text, options
# Test for max/min
if max_value is not None:
if newvalue > max_value:
caller.msg("Field '%s' has a maximum value of %i." % (matched_field, max_value))
text = (None, formhelptext)
return text, options
if min_value is not None:
if newvalue < min_value:
caller.msg("Field '%s' reqiures a minimum value of %i." % (matched_field, min_value))
text = (None, formhelptext)
return text, options
# Field type bool verification
if fieldtype == "bool":
if newvalue.lower() != truestr.lower() and newvalue.lower() != falsestr.lower():
caller.msg("Please enter '%s' or '%s' for field '%s'." % (truestr, falsestr, matched_field))
text = (None, formhelptext)
return text, options
if newvalue.lower() == truestr.lower():
newvalue = True
elif newvalue.lower() == falsestr.lower():
newvalue = False
# Call verify function if present
if verifyfunc:
if verifyfunc(caller, newvalue) is False:
# No error message is given - should be provided by verifyfunc
text = (None, formhelptext)
return text, options
elif verifyfunc(caller, newvalue) is not True:
newvalue = verifyfunc(caller, newvalue)
# Set '0' or '1' to True or False if the field type is bool
if fieldtype == "bool":
if newvalue == 0:
newvalue = False
elif newvalue == 1:
newvalue = True
# If everything checks out, update form!!
formdata.update({matched_field: newvalue})
caller.ndb._menutree.formdata = formdata
# Account for truestr and falsestr when updating a boolean form
announced_newvalue = newvalue
if newvalue is True:
announced_newvalue = truestr
elif newvalue is False:
announced_newvalue = falsestr
# Announce the new value to the player
caller.msg("Field '%s' set to: %s" % (matched_field, str(announced_newvalue)))
text = (None, formhelptext)
return text, options
def form_template_to_dict(formtemplate):
"""
Initializes a dictionary of form data from the given list-of-dictionaries
form template, as formatted above.
Args:
formtemplate (list of dicts): Tempate for the form to be initialized.
Returns:
formdata (dict): Dictionary of initalized form data.
"""
formdata = {}
for field in formtemplate:
# Value is blank by default
fieldvalue = None
if "default" in field:
# Add in default value if present
fieldvalue = field["default"]
formdata.update({field["fieldname"]: fieldvalue})
return formdata
def display_formdata(formtemplate, formdata,
pretext="", posttext="", borderstyle="cells"):
"""
Displays a form's current data as a table. Used in the form menu.
Args:
formtemplate (list of dicts): Template for the form
formdata (dict): Form's current data
Options:
pretext (str): Text to put before the form table.
posttext (str): Text to put after the form table.
borderstyle (str): EvTable's border style.
"""
formtable = evtable.EvTable(border=borderstyle, valign="t", maxwidth=80)
field_name_width = 5
for field in formtemplate:
new_fieldname = None
new_fieldvalue = None
# Get field name
new_fieldname = "|w" + field["fieldname"] + ":|n"
if len(field["fieldname"]) + 5 > field_name_width:
field_name_width = len(field["fieldname"]) + 5
# Get field value
if formdata[field["fieldname"]] is not None:
new_fieldvalue = str(formdata[field["fieldname"]])
# Use blank message if field is blank and once is present
if new_fieldvalue is None and "blankmsg" in field:
new_fieldvalue = "|x" + str(field["blankmsg"]) + "|n"
elif new_fieldvalue is None:
new_fieldvalue = " "
# Replace True and False values with truestr and falsestr from template
if formdata[field["fieldname"]] is True and "truestr" in field:
new_fieldvalue = field["truestr"]
elif formdata[field["fieldname"]] is False and "falsestr" in field:
new_fieldvalue = field["falsestr"]
# Add name and value to table
formtable.add_row(new_fieldname, new_fieldvalue)
formtable.reformat_column(0, align="r", width=field_name_width)
return pretext + "|/" + str(formtable) + "|/" + posttext
# EXAMPLE FUNCTIONS / COMMAND STARTS HERE
def verify_online_player(caller, value):
"""
Example 'verify function' that matches player input to an online character
or else rejects their input as invalid.
Args:
caller (obj): Player entering the form data.
value (str): String player entered into the form, to be verified.
Returns:
matched_character (obj or False): dbref to a currently logged in
character object - reference to the object will be stored in
the form instead of a string. Returns False if no match is
made.
"""
# Get a list of sessions
session_list = SESSIONS.get_sessions()
char_list = []
matched_character = None
# Get a list of online characters
for session in session_list:
if not session.logged_in:
# Skip over logged out characters
continue
# Append to our list of online characters otherwise
char_list.append(session.get_puppet())
# Match player input to a character name
for character in char_list:
if value.lower() == character.key.lower():
matched_character = character
# If input didn't match to a character
if not matched_character:
# Send the player an error message unique to this function
caller.msg("No character matching '%s' is online." % value)
# Returning False indicates the new value is not valid
return False
# Returning anything besides True or False will replace the player's input with the returned
# value. In this case, the value becomes a reference to the character object. You can store data
# besides strings and integers in the 'formdata' dictionary this way!
return matched_character
# Form template for the example 'delayed message' form
SAMPLE_FORM = [
{"fieldname": "Character",
"fieldtype": "text",
"max": 30,
"blankmsg": "(Name of an online player)",
"required": True,
"verifyfunc": verify_online_player
},
{"fieldname": "Delay",
"fieldtype": "number",
"min": 3,
"max": 30,
"default": 10,
"cantclear": True
},
{"fieldname": "Message",
"fieldtype": "text",
"min": 3,
"max": 200,
"blankmsg": "(Message up to 200 characters)"
},
{"fieldname": "Anonymous",
"fieldtype": "bool",
"truestr": "Yes",
"falsestr": "No",
"default": False
}
]
class CmdTestMenu(Command):
"""
This test command will initialize a menu that presents you with a form.
You can fill out the fields of this form in any order, and then type in
'send' to send a message to another online player, which will reach them
after a delay you specify.
Usage:
<field> = <new value>
clear <field>
help
look
quit
send
"""
key = "testmenu"
def func(self):
"""
This performs the actual command.
"""
pretext = "|cSend a delayed message to another player ---------------------------------------|n"
posttext = ("|c--------------------------------------------------------------------------------|n|/"
"Syntax: type |c<field> = <new value>|n to change the values of the form. Given|/"
"player must be currently logged in, delay is given in seconds. When you are|/"
"finished, type '|csend|n' to send the message.|/")
init_fill_field(SAMPLE_FORM, self.caller, init_delayed_message,
pretext=pretext, posttext=posttext,
submitcmd="send", borderstyle="none")
def sendmessage(obj, text):
"""
Callback to send a message to a player.
Args:
obj (obj): Player to message.
text (str): Message.
"""
obj.msg(text)
def init_delayed_message(caller, formdata):
"""
Initializes a delayed message, using data from the example form.
Args:
caller (obj): Character submitting the message.
formdata (dict): Data from submitted form.
"""
# Retrieve data from the filled out form.
# We stored the character to message as an object ref using a verifyfunc
# So we don't have to do any more searching or matching here!
player_to_message = formdata["Character"]
message_delay = formdata["Delay"]
sender = str(caller)
if formdata["Anonymous"] is True:
sender = "anonymous"
message = ("Message from %s: " % sender) + str(formdata["Message"])
caller.msg("Message sent to %s!" % player_to_message)
# Make a deferred call to 'sendmessage' above.
delay(message_delay, sendmessage, player_to_message, message)
return

View file

@ -0,0 +1,103 @@
"""
Health Bar
Contrib - Tim Ashley Jenkins 2017
The function provided in this module lets you easily display visual
bars or meters - "health bar" is merely the most obvious use for this,
though these bars are highly customizable and can be used for any sort
of appropriate data besides player health.
Today's players may be more used to seeing statistics like health,
stamina, magic, and etc. displayed as bars rather than bare numerical
values, so using this module to present this data this way may make it
more accessible. Keep in mind, however, that players may also be using
a screen reader to connect to your game, which will not be able to
represent the colors of the bar in any way. By default, the values
represented are rendered as text inside the bar which can be read by
screen readers.
The health bar will account for current values above the maximum or
below 0, rendering them as a completely full or empty bar with the
values displayed within.
"""
def display_meter(cur_value, max_value,
length=30, fill_color=["R", "Y", "G"],
empty_color="B", text_color="w",
align="left", pre_text="", post_text="",
show_values=True):
"""
Represents a current and maximum value given as a "bar" rendered with
ANSI or xterm256 background colors.
Args:
cur_value (int): Current value to display
max_value (int): Maximum value to display
Options:
length (int): Length of meter returned, in characters
fill_color (list): List of color codes for the full portion
of the bar, sans any sort of prefix - both ANSI and xterm256
colors are usable. When the bar is empty, colors toward the
start of the list will be chosen - when the bar is full, colors
towards the end are picked. You can adjust the 'weights' of
the changing colors by adding multiple entries of the same
color - for example, if you only want the bar to change when
it's close to empty, you could supply ['R','Y','G','G','G']
empty_color (str): Color code for the empty portion of the bar.
text_color (str): Color code for text inside the bar.
align (str): "left", "right", or "center" - alignment of text in the bar
pre_text (str): Text to put before the numbers in the bar
post_text (str): Text to put after the numbers in the bar
show_values (bool): If true, shows the numerical values represented by
the bar. It's highly recommended you keep this on, especially if
there's no info given in pre_text or post_text, as players on screen
readers will be unable to read the graphical aspect of the bar.
"""
# Start by building the base string.
num_text = ""
if show_values:
num_text = "%i / %i" % (cur_value, max_value)
bar_base_str = pre_text + num_text + post_text
# Cut down the length of the base string if needed
if len(bar_base_str) > length:
bar_base_str = bar_base_str[:length]
# Pad and align the bar base string
if align == "right":
bar_base_str = bar_base_str.rjust(length, " ")
elif align == "center":
bar_base_str = bar_base_str.center(length, " ")
else:
bar_base_str = bar_base_str.ljust(length, " ")
if max_value < 1: # Prevent divide by zero
max_value = 1
if cur_value < 0: # Prevent weirdly formatted 'negative bars'
cur_value = 0
if cur_value > max_value: # Display overfull bars correctly
cur_value = max_value
# Now it's time to determine where to put the color codes.
percent_full = float(cur_value) / float(max_value)
split_index = round(float(length) * percent_full)
# Determine point at which to split the bar
split_index = int(split_index)
# Separate the bar string into full and empty portions
full_portion = bar_base_str[:split_index]
empty_portion = bar_base_str[split_index:]
# Pick which fill color to use based on how full the bar is
fillcolor_index = (float(len(fill_color)) * percent_full)
fillcolor_index = int(round(fillcolor_index)) - 1
fillcolor_code = "|[" + fill_color[fillcolor_index]
# Make color codes for empty bar portion and text_color
emptycolor_code = "|[" + empty_color
textcolor_code = "|" + text_color
# Assemble the final bar
final_bar = fillcolor_code + textcolor_code + full_portion + "|n" + emptycolor_code + textcolor_code + empty_portion + "|n"
return final_bar

View file

@ -430,7 +430,7 @@ class EventCharacter(DefaultCharacter):
# Browse all the room's other characters
for obj in location.contents:
if obj is self or not inherits_from(obj, "objects.objects.DefaultCharacter"):
if obj is self or not inherits_from(obj, "evennia.objects.objects.DefaultCharacter"):
continue
allow = obj.callbacks.call("can_say", self, obj, message, parameters=message)
@ -491,7 +491,7 @@ class EventCharacter(DefaultCharacter):
parameters=message)
# Call the other characters' "say" event
presents = [obj for obj in location.contents if obj is not self and inherits_from(obj, "objects.objects.DefaultCharacter")]
presents = [obj for obj in location.contents if obj is not self and inherits_from(obj, "evennia.objects.objects.DefaultCharacter")]
for present in presents:
present.callbacks.call("say", self, present, message, parameters=message)

View file

@ -0,0 +1,5 @@
# Security
This directory contains security-related contribs
- Auditing (Johnny 2018) - Allow for optional security logging of user input/output.

View file

View file

@ -0,0 +1,72 @@
# Input/Output Auditing
Contrib - Johnny 2017
This is a tap that optionally intercepts all data sent to/from clients and the
server and passes it to a callback of your choosing.
It is intended for quality assurance, post-incident investigations and debugging
but obviously can be abused. All data is recorded in cleartext. Please
be ethical, and if you are unwilling to properly deal with the implications of
recording user passwords or private communications, please do not enable
this module.
Some checks have been implemented to protect the privacy of users.
Files included in this module:
outputs.py - Example callback methods. This module ships with examples of
callbacks that send data as JSON to a file in your game/server/logs
dir or to your native Linux syslog daemon. You can of course write
your own to do other things like post them to Kafka topics.
server.py - Extends the Evennia ServerSession object to pipe data to the
callback upon receipt.
tests.py - Unit tests that check to make sure commands with sensitive
arguments are having their PII scrubbed.
Installation/Configuration:
Deployment is completed by configuring a few settings in server.conf. This line
is required:
SERVER_SESSION_CLASS = 'evennia.contrib.security.auditing.server.AuditedServerSession'
This tells Evennia to use this ServerSession instead of its own. Below are the
other possible options along with the default value that will be used if unset.
# Where to send logs? Define the path to a module containing your callback
# function. It should take a single dict argument as input
AUDIT_CALLBACK = 'evennia.contrib.security.auditing.outputs.to_file'
# Log user input? Be ethical about this; it will log all private and
# public communications between players and/or admins (default: False).
AUDIT_IN = False
# Log server output? This will result in logging of ALL system
# messages and ALL broadcasts to connected players, so on a busy game any
# broadcast to all users will yield a single event for every connected user!
AUDIT_OUT = False
# The default output is a dict. Do you want to allow key:value pairs with
# null/blank values? If you're just writing to disk, disabling this saves
# some disk space, but whether you *want* sparse values or not is more of a
# consideration if you're shipping logs to a NoSQL/schemaless database.
# (default: False)
AUDIT_ALLOW_SPARSE = False
# If you write custom commands that handle sensitive data like passwords,
# you must write a regular expression to remove that before writing to log.
# AUDIT_MASKS is a list of dictionaries that define the names of commands
# and the regexes needed to scrub them.
# The system already has defaults to filter out sensitive login/creation
# commands in the default command set. Your list of AUDIT_MASKS will be appended
# to those defaults.
#
# In the regex, the sensitive data itself must be captured in a named group with a
# label of 'secret' (see the Python docs on the `re` module for more info). For
# example: `{'authentication': r"^@auth\s+(?P<secret>[\w]+)"}`
AUDIT_MASKS = []

View file

@ -0,0 +1,60 @@
"""
Auditable Server Sessions - Example Outputs
Example methods demonstrating output destinations for logs generated by
audited server sessions.
This is designed to be a single source of events for developers to customize
and add any additional enhancements before events are written out-- i.e. if you
want to keep a running list of what IPs a user logs in from on account/character
objects, or if you want to perform geoip or ASN lookups on IPs before committing,
or tag certain events with the results of a reputational lookup, this should be
the easiest place to do it. Write a method and invoke it via
`settings.AUDIT_CALLBACK` to have log data objects passed to it.
Evennia contribution - Johnny 2017
"""
from evennia.utils.logger import log_file
import json
import syslog
def to_file(data):
"""
Writes dictionaries of data generated by an AuditedServerSession to files
in JSON format, bucketed by date.
Uses Evennia's native logger and writes to the default
log directory (~/yourgame/server/logs/ or settings.LOG_DIR)
Args:
data (dict): Parsed session transmission data.
"""
# Bucket logs by day and remove objects before serialization
bucket = data.pop('objects')['time'].strftime('%Y-%m-%d')
# Write it
log_file(json.dumps(data), filename="audit_%s.log" % bucket)
def to_syslog(data):
"""
Writes dictionaries of data generated by an AuditedServerSession to syslog.
Takes advantage of your system's native logger and writes to wherever
you have it configured, which is independent of Evennia.
Linux systems tend to write to /var/log/syslog.
If you're running rsyslog, you can configure it to dump and/or forward logs
to disk and/or an external data warehouse (recommended-- if your server is
compromised or taken down, losing your logs along with it is no help!).
Args:
data (dict): Parsed session transmission data.
"""
# Remove objects before serialization
data.pop('objects')
# Write it out
syslog.syslog(json.dumps(data))

View file

@ -0,0 +1,241 @@
"""
Auditable Server Sessions:
Extension of the stock ServerSession that yields objects representing
user inputs and system outputs.
Evennia contribution - Johnny 2017
"""
import os
import re
import socket
from django.utils import timezone
from django.conf import settings as ev_settings
from evennia.utils import utils, logger, mod_import, get_evennia_version
from evennia.server.serversession import ServerSession
# Attributes governing auditing of commands and where to send log objects
AUDIT_CALLBACK = getattr(ev_settings, 'AUDIT_CALLBACK',
'evennia.contrib.security.auditing.outputs.to_file')
AUDIT_IN = getattr(ev_settings, 'AUDIT_IN', False)
AUDIT_OUT = getattr(ev_settings, 'AUDIT_OUT', False)
AUDIT_ALLOW_SPARSE = getattr(ev_settings, 'AUDIT_ALLOW_SPARSE', False)
AUDIT_MASKS = [
{'connect': r"^[@\s]*[connect]{5,8}\s+(\".+?\"|[^\s]+)\s+(?P<secret>.+)"},
{'connect': r"^[@\s]*[connect]{5,8}\s+(?P<secret>[\w]+)"},
{'create': r"^[^@]?[create]{5,6}\s+(\w+|\".+?\")\s+(?P<secret>[\w]+)"},
{'create': r"^[^@]?[create]{5,6}\s+(?P<secret>[\w]+)"},
{'userpassword': r"^[@\s]*[userpassword]{11,14}\s+(\w+|\".+?\")\s+=*\s*(?P<secret>[\w]+)"},
{'userpassword': r"^.*new password set to '(?P<secret>[^']+)'\."},
{'userpassword': r"^.* has changed your password to '(?P<secret>[^']+)'\."},
{'password': r"^[@\s]*[password]{6,9}\s+(?P<secret>.*)"},
] + getattr(ev_settings, 'AUDIT_MASKS', [])
if AUDIT_CALLBACK:
try:
AUDIT_CALLBACK = getattr(
mod_import('.'.join(AUDIT_CALLBACK.split('.')[:-1])), AUDIT_CALLBACK.split('.')[-1])
logger.log_sec("Auditing module online.")
logger.log_sec("Audit record User input: {}, output: {}.\n"
"Audit sparse recording: {}, Log callback: {}".format(
AUDIT_IN, AUDIT_OUT, AUDIT_ALLOW_SPARSE, AUDIT_CALLBACK))
except Exception as e:
logger.log_err("Failed to activate Auditing module. %s" % e)
class AuditedServerSession(ServerSession):
"""
This particular implementation parses all server inputs and/or outputs and
passes a dict containing the parsed metadata to a callback method of your
creation. This is useful for recording player activity where necessary for
security auditing, usage analysis or post-incident forensic discovery.
*** WARNING ***
All strings are recorded and stored in plaintext. This includes those strings
which might contain sensitive data (create, connect, @password). These commands
have their arguments masked by default, but you must mask or mask any
custom commands of your own that handle sensitive information.
See README.md for installation/configuration instructions.
"""
def audit(self, **kwargs):
"""
Extracts messages and system data from a Session object upon message
send or receive.
Kwargs:
src (str): Source of data; 'client' or 'server'. Indicates direction.
text (str or list): Client sends messages to server in the form of
lists. Server sends messages to client as string.
Returns:
log (dict): Dictionary object containing parsed system and user data
related to this message.
"""
# Get time at start of processing
time_obj = timezone.now()
time_str = str(time_obj)
session = self
src = kwargs.pop('src', '?')
bytecount = 0
# Do not log empty lines
if not kwargs:
return {}
# Get current session's IP address
client_ip = session.address
# Capture Account name and dbref together
account = session.get_account()
account_token = ''
if account:
account_token = '%s%s' % (account.key, account.dbref)
# Capture Character name and dbref together
char = session.get_puppet()
char_token = ''
if char:
char_token = '%s%s' % (char.key, char.dbref)
# Capture Room name and dbref together
room = None
room_token = ''
if char:
room = char.location
room_token = '%s%s' % (room.key, room.dbref)
# Try to compile an input/output string
def drill(obj, bucket):
if isinstance(obj, dict):
return bucket
elif utils.is_iter(obj):
for sub_obj in obj:
bucket.extend(drill(sub_obj, []))
else:
bucket.append(obj)
return bucket
text = kwargs.pop('text', '')
if utils.is_iter(text):
text = '|'.join(drill(text, []))
# Mask any PII in message, where possible
bytecount = len(text.encode('utf-8'))
text = self.mask(text)
# Compile the IP, Account, Character, Room, and the message.
log = {
'time': time_str,
'hostname': socket.getfqdn(),
'application': '%s' % ev_settings.SERVERNAME,
'version': get_evennia_version(),
'pid': os.getpid(),
'direction': 'SND' if src == 'server' else 'RCV',
'protocol': self.protocol_key,
'ip': client_ip,
'session': 'session#%s' % self.sessid,
'account': account_token,
'character': char_token,
'room': room_token,
'text': text.strip(),
'bytes': bytecount,
'data': kwargs,
'objects': {
'time': time_obj,
'session': self,
'account': account,
'character': char,
'room': room,
}
}
# Remove any keys with blank values
if AUDIT_ALLOW_SPARSE is False:
log['data'] = {k: v for k, v in log['data'].iteritems() if v}
log['objects'] = {k: v for k, v in log['objects'].iteritems() if v}
log = {k: v for k, v in log.iteritems() if v}
return log
def mask(self, msg):
"""
Masks potentially sensitive user information within messages before
writing to log. Recording cleartext password attempts is bad policy.
Args:
msg (str): Raw text string sent from client <-> server
Returns:
msg (str): Text string with sensitive information masked out.
"""
# Check to see if the command is embedded within server output
_msg = msg
is_embedded = False
match = re.match(".*Command.*'(.+)'.*is not available.*", msg, flags=re.IGNORECASE)
if match:
msg = match.group(1).replace('\\', '')
submsg = msg
is_embedded = True
for mask in AUDIT_MASKS:
for command, regex in mask.iteritems():
try:
match = re.match(regex, msg, flags=re.IGNORECASE)
except Exception as e:
logger.log_err(regex)
logger.log_err(e)
continue
if match:
term = match.group('secret')
masked = re.sub(term, '*' * len(term.zfill(8)), msg)
if is_embedded:
msg = re.sub(submsg, '%s <Masked: %s>' % (masked, command), _msg, flags=re.IGNORECASE)
else:
msg = masked
return msg
return _msg
def data_out(self, **kwargs):
"""
Generic hook for sending data out through the protocol.
Kwargs:
kwargs (any): Other data to the protocol.
"""
if AUDIT_CALLBACK and AUDIT_OUT:
try:
log = self.audit(src='server', **kwargs)
if log:
AUDIT_CALLBACK(log)
except Exception as e:
logger.log_err(e)
super(AuditedServerSession, self).data_out(**kwargs)
def data_in(self, **kwargs):
"""
Hook for protocols to send incoming data to the engine.
Kwargs:
kwargs (any): Other data from the protocol.
"""
if AUDIT_CALLBACK and AUDIT_IN:
try:
log = self.audit(src='client', **kwargs)
if log:
AUDIT_CALLBACK(log)
except Exception as e:
logger.log_err(e)
super(AuditedServerSession, self).data_in(**kwargs)

View file

@ -0,0 +1,95 @@
"""
Module containing the test cases for the Audit system.
"""
from django.conf import settings
from evennia.utils.test_resources import EvenniaTest
import re
# Configure session auditing settings
settings.AUDIT_CALLBACK = "evennia.security.contrib.auditing.outputs.to_syslog"
settings.AUDIT_IN = True
settings.AUDIT_OUT = True
settings.AUDIT_ALLOW_SPARSE = True
# Configure settings to use custom session
settings.SERVER_SESSION_CLASS = "evennia.contrib.security.auditing.server.AuditedServerSession"
class AuditingTest(EvenniaTest):
def test_mask(self):
"""
Make sure the 'mask' function is properly masking potentially sensitive
information from strings.
"""
safe_cmds = (
'/say hello to my little friend',
'@ccreate channel = for channeling',
'@create/drop some stuff',
'@create rock',
'@create a pretty shirt : evennia.contrib.clothing.Clothing',
'@charcreate johnnyefhiwuhefwhef',
'Command "@logout" is not available. Maybe you meant "@color" or "@cboot"?',
'/me says, "what is the password?"',
'say the password is plugh',
# Unfortunately given the syntax, there is no way to discern the
# latter of these as sensitive
'@create pretty sunset'
'@create johnny password123',
'{"text": "Command \'do stuff\' is not available. Type \"help\" for help."}'
)
for cmd in safe_cmds:
self.assertEqual(self.session.mask(cmd), cmd)
unsafe_cmds = (
("something - new password set to 'asdfghjk'.", "something - new password set to '********'."),
("someone has changed your password to 'something'.", "someone has changed your password to '*********'."),
('connect johnny password123', 'connect johnny ***********'),
('concnct johnny password123', 'concnct johnny ***********'),
('concnct johnnypassword123', 'concnct *****************'),
('connect "johnny five" "password 123"', 'connect "johnny five" **************'),
('connect johnny "password 123"', 'connect johnny **************'),
('create johnny password123', 'create johnny ***********'),
('@password password1234 = password2345', '@password ***************************'),
('@password password1234 password2345', '@password *************************'),
('@passwd password1234 = password2345', '@passwd ***************************'),
('@userpassword johnny = password234', '@userpassword johnny = ***********'),
('craete johnnypassword123', 'craete *****************'),
("Command 'conncect teddy teddy' is not available. Maybe you meant \"@encode\"?", 'Command \'conncect ******** ********\' is not available. Maybe you meant "@encode"?'),
("{'text': u'Command \\'conncect jsis dfiidf\\' is not available. Type \"help\" for help.'}", "{'text': u'Command \\'conncect jsis ********\\' is not available. Type \"help\" for help.'}")
)
for index, (unsafe, safe) in enumerate(unsafe_cmds):
self.assertEqual(re.sub(' <Masked: .+>', '', self.session.mask(unsafe)).strip(), safe)
# Make sure scrubbing is not being abused to evade monitoring
secrets = [
'say password password password; ive got a secret that i cant explain',
'whisper johnny = password\n let\'s lynch the landlord',
'say connect johnny password1234|the secret life of arabia',
"@password eval(\"__import__('os').system('clear')\", {'__builtins__':{}})"
]
for secret in secrets:
self.assertEqual(self.session.mask(secret), secret)
def test_audit(self):
"""
Make sure the 'audit' function is returning a dictionary based on values
parsed from the Session object.
"""
log = self.session.audit(src='client', text=[['hello']])
obj = {k:v for k,v in log.iteritems() if k in ('direction', 'protocol', 'application', 'text')}
self.assertEqual(obj, {
'direction': 'RCV',
'protocol': 'telnet',
'application': 'Evennia',
'text': 'hello'
})
# Make sure OOB data is being recorded
log = self.session.audit(src='client', text="connect johnny password123", prompt="hp=20|st=10|ma=15", pane=2)
self.assertEqual(log['text'], 'connect johnny ***********')
self.assertEqual(log['data']['prompt'], 'hp=20|st=10|ma=15')
self.assertEqual(log['data']['pane'], 2)

View file

@ -671,6 +671,15 @@ class TestGenderSub(CommandTest):
txt = "Test |p gender"
self.assertEqual(gendersub._RE_GENDER_PRONOUN.sub(char._get_pronoun, txt), "Test their gender")
# test health bar contrib
from evennia.contrib import health_bar
class TestHealthBar(EvenniaTest):
def test_healthbar(self):
expected_bar_str = "|[G|w |n|[B|w test24 / 200test |n"
self.assertTrue(health_bar.display_meter(24, 200, length=40, pre_text="test", post_text="test", align="center") == expected_bar_str)
# test mail contrib
@ -789,7 +798,7 @@ from evennia.contrib import talking_npc
class TestTalkingNPC(CommandTest):
def test_talkingnpc(self):
npc = create_object(talking_npc.TalkingNPC, key="npctalker", location=self.room1)
self.call(talking_npc.CmdTalk(), "", "(You walk up and talk to Char.)|")
self.call(talking_npc.CmdTalk(), "", "(You walk up and talk to Char.)")
npc.delete()
@ -944,101 +953,637 @@ class TestTutorialWorldRooms(CommandTest):
# test turnbattle
from evennia.contrib import turnbattle
from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range, tb_items, tb_magic
from evennia.objects.objects import DefaultRoom
class TestTurnBattleCmd(CommandTest):
class TestTurnBattleBasicCmd(CommandTest):
# Test combat commands
# Test basic combat commands
def test_turnbattlecmd(self):
self.call(turnbattle.CmdFight(), "", "You can't start a fight if you've been defeated!")
self.call(turnbattle.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
self.call(turnbattle.CmdPass(), "", "You can only do that in combat. (see: help fight)")
self.call(turnbattle.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
self.call(turnbattle.CmdRest(), "", "Char rests to recover HP.")
self.call(tb_basic.CmdFight(), "", "You can't start a fight if you've been defeated!")
self.call(tb_basic.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_basic.CmdPass(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_basic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_basic.CmdRest(), "", "Char rests to recover HP.")
class TestTurnBattleFunc(EvenniaTest):
class TestTurnBattleEquipCmd(CommandTest):
def setUp(self):
super(TestTurnBattleEquipCmd, self).setUp()
self.testweapon = create_object(tb_equip.TBEWeapon, key="test weapon")
self.testarmor = create_object(tb_equip.TBEArmor, key="test armor")
self.testweapon.move_to(self.char1)
self.testarmor.move_to(self.char1)
# Test equipment commands
def test_turnbattleequipcmd(self):
# Start with equip module specific commands.
self.call(tb_equip.CmdWield(), "weapon", "Char wields test weapon.")
self.call(tb_equip.CmdUnwield(), "", "Char lowers test weapon.")
self.call(tb_equip.CmdDon(), "armor", "Char dons test armor.")
self.call(tb_equip.CmdDoff(), "", "Char removes test armor.")
# Also test the commands that are the same in the basic module
self.call(tb_equip.CmdFight(), "", "You can't start a fight if you've been defeated!")
self.call(tb_equip.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_equip.CmdPass(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_equip.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_equip.CmdRest(), "", "Char rests to recover HP.")
class TestTurnBattleRangeCmd(CommandTest):
# Test range commands
def test_turnbattlerangecmd(self):
# Start with range module specific commands.
self.call(tb_range.CmdShoot(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_range.CmdApproach(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_range.CmdWithdraw(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_range.CmdStatus(), "", "HP Remaining: 100 / 100")
# Also test the commands that are the same in the basic module
self.call(tb_range.CmdFight(), "", "There's nobody here to fight!")
self.call(tb_range.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_range.CmdPass(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_range.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_range.CmdRest(), "", "Char rests to recover HP.")
class TestTurnBattleItemsCmd(CommandTest):
def setUp(self):
super(TestTurnBattleItemsCmd, self).setUp()
self.testitem = create_object(key="test item")
self.testitem.move_to(self.char1)
# Test item commands
def test_turnbattleitemcmd(self):
self.call(tb_items.CmdUse(), "item", "'Test item' is not a usable item.")
# Also test the commands that are the same in the basic module
self.call(tb_items.CmdFight(), "", "You can't start a fight if you've been defeated!")
self.call(tb_items.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_items.CmdPass(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_items.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_items.CmdRest(), "", "Char rests to recover HP.")
class TestTurnBattleMagicCmd(CommandTest):
# Test magic commands
def test_turnbattlemagiccmd(self):
self.call(tb_magic.CmdStatus(), "", "You have 100 / 100 HP and 20 / 20 MP.")
self.call(tb_magic.CmdLearnSpell(), "test spell", "There is no spell with that name.")
self.call(tb_magic.CmdCast(), "", "Usage: cast <spell name> = <target>, <target2>")
# Also test the commands that are the same in the basic module
self.call(tb_magic.CmdFight(), "", "There's nobody here to fight!")
self.call(tb_magic.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_magic.CmdPass(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_magic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_magic.CmdRest(), "", "Char rests to recover HP and MP.")
class TestTurnBattleBasicFunc(EvenniaTest):
def setUp(self):
super(TestTurnBattleBasicFunc, self).setUp()
self.testroom = create_object(DefaultRoom, key="Test Room")
self.attacker = create_object(tb_basic.TBBasicCharacter, key="Attacker", location=self.testroom)
self.defender = create_object(tb_basic.TBBasicCharacter, key="Defender", location=self.testroom)
self.joiner = create_object(tb_basic.TBBasicCharacter, key="Joiner", location=None)
def tearDown(self):
super(TestTurnBattleBasicFunc, self).tearDown()
self.attacker.delete()
self.defender.delete()
self.joiner.delete()
self.testroom.delete()
self.turnhandler.stop()
# Test combat functions
def test_turnbattlefunc(self):
attacker = create_object(turnbattle.BattleCharacter, key="Attacker")
defender = create_object(turnbattle.BattleCharacter, key="Defender")
testroom = create_object(DefaultRoom, key="Test Room")
attacker.location = testroom
defender.loaction = testroom
def test_tbbasicfunc(self):
# Initiative roll
initiative = turnbattle.roll_init(attacker)
initiative = tb_basic.roll_init(self.attacker)
self.assertTrue(initiative >= 0 and initiative <= 1000)
# Attack roll
attack_roll = turnbattle.get_attack(attacker, defender)
attack_roll = tb_basic.get_attack(self.attacker, self.defender)
self.assertTrue(attack_roll >= 0 and attack_roll <= 100)
# Defense roll
defense_roll = turnbattle.get_defense(attacker, defender)
defense_roll = tb_basic.get_defense(self.attacker, self.defender)
self.assertTrue(defense_roll == 50)
# Damage roll
damage_roll = turnbattle.get_damage(attacker, defender)
damage_roll = tb_basic.get_damage(self.attacker, self.defender)
self.assertTrue(damage_roll >= 15 and damage_roll <= 25)
# Apply damage
defender.db.hp = 10
turnbattle.apply_damage(defender, 3)
self.assertTrue(defender.db.hp == 7)
self.defender.db.hp = 10
tb_basic.apply_damage(self.defender, 3)
self.assertTrue(self.defender.db.hp == 7)
# Resolve attack
defender.db.hp = 40
turnbattle.resolve_attack(attacker, defender, attack_value=20, defense_value=10)
self.assertTrue(defender.db.hp < 40)
self.defender.db.hp = 40
tb_basic.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10)
self.assertTrue(self.defender.db.hp < 40)
# Combat cleanup
attacker.db.Combat_attribute = True
turnbattle.combat_cleanup(attacker)
self.assertFalse(attacker.db.combat_attribute)
self.attacker.db.Combat_attribute = True
tb_basic.combat_cleanup(self.attacker)
self.assertFalse(self.attacker.db.combat_attribute)
# Is in combat
self.assertFalse(turnbattle.is_in_combat(attacker))
self.assertFalse(tb_basic.is_in_combat(self.attacker))
# Set up turn handler script for further tests
attacker.location.scripts.add(turnbattle.TurnHandler)
turnhandler = attacker.db.combat_TurnHandler
self.assertTrue(attacker.db.combat_TurnHandler)
self.attacker.location.scripts.add(tb_basic.TBBasicTurnHandler)
self.turnhandler = self.attacker.db.combat_TurnHandler
self.assertTrue(self.attacker.db.combat_TurnHandler)
# Set the turn handler's interval very high to keep it from repeating during tests.
self.turnhandler.interval = 10000
# Force turn order
turnhandler.db.fighters = [attacker, defender]
turnhandler.db.turn = 0
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
# Test is turn
self.assertTrue(turnbattle.is_turn(attacker))
self.assertTrue(tb_basic.is_turn(self.attacker))
# Spend actions
attacker.db.Combat_ActionsLeft = 1
turnbattle.spend_action(attacker, 1, action_name="Test")
self.assertTrue(attacker.db.Combat_ActionsLeft == 0)
self.assertTrue(attacker.db.Combat_LastAction == "Test")
self.attacker.db.Combat_ActionsLeft = 1
tb_basic.spend_action(self.attacker, 1, action_name="Test")
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
self.assertTrue(self.attacker.db.Combat_LastAction == "Test")
# Initialize for combat
attacker.db.Combat_ActionsLeft = 983
turnhandler.initialize_for_combat(attacker)
self.assertTrue(attacker.db.Combat_ActionsLeft == 0)
self.assertTrue(attacker.db.Combat_LastAction == "null")
self.attacker.db.Combat_ActionsLeft = 983
self.turnhandler.initialize_for_combat(self.attacker)
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
self.assertTrue(self.attacker.db.Combat_LastAction == "null")
# Start turn
defender.db.Combat_ActionsLeft = 0
turnhandler.start_turn(defender)
self.assertTrue(defender.db.Combat_ActionsLeft == 1)
self.defender.db.Combat_ActionsLeft = 0
self.turnhandler.start_turn(self.defender)
self.assertTrue(self.defender.db.Combat_ActionsLeft == 1)
# Next turn
turnhandler.db.fighters = [attacker, defender]
turnhandler.db.turn = 0
turnhandler.next_turn()
self.assertTrue(turnhandler.db.turn == 1)
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
self.turnhandler.next_turn()
self.assertTrue(self.turnhandler.db.turn == 1)
# Turn end check
turnhandler.db.fighters = [attacker, defender]
turnhandler.db.turn = 0
attacker.db.Combat_ActionsLeft = 0
turnhandler.turn_end_check(attacker)
self.assertTrue(turnhandler.db.turn == 1)
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
self.attacker.db.Combat_ActionsLeft = 0
self.turnhandler.turn_end_check(self.attacker)
self.assertTrue(self.turnhandler.db.turn == 1)
# Join fight
joiner = create_object(turnbattle.BattleCharacter, key="Joiner")
turnhandler.db.fighters = [attacker, defender]
turnhandler.db.turn = 0
turnhandler.join_fight(joiner)
self.assertTrue(turnhandler.db.turn == 1)
self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender])
# Remove the script at the end
turnhandler.stop()
self.joiner.location = self.testroom
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
self.turnhandler.join_fight(self.joiner)
self.assertTrue(self.turnhandler.db.turn == 1)
self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender])
class TestTurnBattleEquipFunc(EvenniaTest):
def setUp(self):
super(TestTurnBattleEquipFunc, self).setUp()
self.testroom = create_object(DefaultRoom, key="Test Room")
self.attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker", location=self.testroom)
self.defender = create_object(tb_equip.TBEquipCharacter, key="Defender", location=self.testroom)
self.joiner = create_object(tb_equip.TBEquipCharacter, key="Joiner", location=None)
def tearDown(self):
super(TestTurnBattleEquipFunc, self).tearDown()
self.attacker.delete()
self.defender.delete()
self.joiner.delete()
self.testroom.delete()
self.turnhandler.stop()
# Test the combat functions in tb_equip too. They work mostly the same.
def test_tbequipfunc(self):
# Initiative roll
initiative = tb_equip.roll_init(self.attacker)
self.assertTrue(initiative >= 0 and initiative <= 1000)
# Attack roll
attack_roll = tb_equip.get_attack(self.attacker, self.defender)
self.assertTrue(attack_roll >= -50 and attack_roll <= 150)
# Defense roll
defense_roll = tb_equip.get_defense(self.attacker, self.defender)
self.assertTrue(defense_roll == 50)
# Damage roll
damage_roll = tb_equip.get_damage(self.attacker, self.defender)
self.assertTrue(damage_roll >= 0 and damage_roll <= 50)
# Apply damage
self.defender.db.hp = 10
tb_equip.apply_damage(self.defender, 3)
self.assertTrue(self.defender.db.hp == 7)
# Resolve attack
self.defender.db.hp = 40
tb_equip.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10)
self.assertTrue(self.defender.db.hp < 40)
# Combat cleanup
self.attacker.db.Combat_attribute = True
tb_equip.combat_cleanup(self.attacker)
self.assertFalse(self.attacker.db.combat_attribute)
# Is in combat
self.assertFalse(tb_equip.is_in_combat(self.attacker))
# Set up turn handler script for further tests
self.attacker.location.scripts.add(tb_equip.TBEquipTurnHandler)
self.turnhandler = self.attacker.db.combat_TurnHandler
self.assertTrue(self.attacker.db.combat_TurnHandler)
# Set the turn handler's interval very high to keep it from repeating during tests.
self.turnhandler.interval = 10000
# Force turn order
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
# Test is turn
self.assertTrue(tb_equip.is_turn(self.attacker))
# Spend actions
self.attacker.db.Combat_ActionsLeft = 1
tb_equip.spend_action(self.attacker, 1, action_name="Test")
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
self.assertTrue(self.attacker.db.Combat_LastAction == "Test")
# Initialize for combat
self.attacker.db.Combat_ActionsLeft = 983
self.turnhandler.initialize_for_combat(self.attacker)
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
self.assertTrue(self.attacker.db.Combat_LastAction == "null")
# Start turn
self.defender.db.Combat_ActionsLeft = 0
self.turnhandler.start_turn(self.defender)
self.assertTrue(self.defender.db.Combat_ActionsLeft == 1)
# Next turn
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
self.turnhandler.next_turn()
self.assertTrue(self.turnhandler.db.turn == 1)
# Turn end check
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
self.attacker.db.Combat_ActionsLeft = 0
self.turnhandler.turn_end_check(self.attacker)
self.assertTrue(self.turnhandler.db.turn == 1)
# Join fight
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
self.turnhandler.join_fight(self.joiner)
self.assertTrue(self.turnhandler.db.turn == 1)
self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender])
class TestTurnBattleRangeFunc(EvenniaTest):
def setUp(self):
super(TestTurnBattleRangeFunc, self).setUp()
self.testroom = create_object(DefaultRoom, key="Test Room")
self.attacker = create_object(tb_range.TBRangeCharacter, key="Attacker", location=self.testroom)
self.defender = create_object(tb_range.TBRangeCharacter, key="Defender", location=self.testroom)
self.joiner = create_object(tb_range.TBRangeCharacter, key="Joiner", location=self.testroom)
def tearDown(self):
super(TestTurnBattleRangeFunc, self).tearDown()
self.attacker.delete()
self.defender.delete()
self.joiner.delete()
self.testroom.delete()
self.turnhandler.stop()
# Test combat functions in tb_range too.
def test_tbrangefunc(self):
# Initiative roll
initiative = tb_range.roll_init(self.attacker)
self.assertTrue(initiative >= 0 and initiative <= 1000)
# Attack roll
attack_roll = tb_range.get_attack(self.attacker, self.defender, "test")
self.assertTrue(attack_roll >= 0 and attack_roll <= 100)
# Defense roll
defense_roll = tb_range.get_defense(self.attacker, self.defender, "test")
self.assertTrue(defense_roll == 50)
# Damage roll
damage_roll = tb_range.get_damage(self.attacker, self.defender)
self.assertTrue(damage_roll >= 15 and damage_roll <= 25)
# Apply damage
self.defender.db.hp = 10
tb_range.apply_damage(self.defender, 3)
self.assertTrue(self.defender.db.hp == 7)
# Resolve attack
self.defender.db.hp = 40
tb_range.resolve_attack(self.attacker, self.defender, "test", attack_value=20, defense_value=10)
self.assertTrue(self.defender.db.hp < 40)
# Combat cleanup
self.attacker.db.Combat_attribute = True
tb_range.combat_cleanup(self.attacker)
self.assertFalse(self.attacker.db.combat_attribute)
# Is in combat
self.assertFalse(tb_range.is_in_combat(self.attacker))
# Set up turn handler script for further tests
self.attacker.location.scripts.add(tb_range.TBRangeTurnHandler)
self.turnhandler = self.attacker.db.combat_TurnHandler
self.assertTrue(self.attacker.db.combat_TurnHandler)
# Set the turn handler's interval very high to keep it from repeating during tests.
self.turnhandler.interval = 10000
# Force turn order
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
# Test is turn
self.assertTrue(tb_range.is_turn(self.attacker))
# Spend actions
self.attacker.db.Combat_ActionsLeft = 1
tb_range.spend_action(self.attacker, 1, action_name="Test")
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
self.assertTrue(self.attacker.db.Combat_LastAction == "Test")
# Initialize for combat
self.attacker.db.Combat_ActionsLeft = 983
self.turnhandler.initialize_for_combat(self.attacker)
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
self.assertTrue(self.attacker.db.Combat_LastAction == "null")
# Set up ranges again, since initialize_for_combat clears them
self.attacker.db.combat_range = {}
self.attacker.db.combat_range[self.attacker] = 0
self.attacker.db.combat_range[self.defender] = 1
self.defender.db.combat_range = {}
self.defender.db.combat_range[self.defender] = 0
self.defender.db.combat_range[self.attacker] = 1
# Start turn
self.defender.db.Combat_ActionsLeft = 0
self.turnhandler.start_turn(self.defender)
self.assertTrue(self.defender.db.Combat_ActionsLeft == 2)
# Next turn
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
self.turnhandler.next_turn()
self.assertTrue(self.turnhandler.db.turn == 1)
# Turn end check
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
self.attacker.db.Combat_ActionsLeft = 0
self.turnhandler.turn_end_check(self.attacker)
self.assertTrue(self.turnhandler.db.turn == 1)
# Join fight
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
self.turnhandler.join_fight(self.joiner)
self.assertTrue(self.turnhandler.db.turn == 1)
self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender])
# Now, test for approach/withdraw functions
self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 1)
# Approach
tb_range.approach(self.attacker, self.defender)
self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 0)
# Withdraw
tb_range.withdraw(self.attacker, self.defender)
self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 1)
class TestTurnBattleItemsFunc(EvenniaTest):
@patch("evennia.contrib.turnbattle.tb_items.tickerhandler", new=MagicMock())
def setUp(self):
super(TestTurnBattleItemsFunc, self).setUp()
self.testroom = create_object(DefaultRoom, key="Test Room")
self.attacker = create_object(tb_items.TBItemsCharacter, key="Attacker", location=self.testroom)
self.defender = create_object(tb_items.TBItemsCharacter, key="Defender", location=self.testroom)
self.joiner = create_object(tb_items.TBItemsCharacter, key="Joiner", location=self.testroom)
self.user = create_object(tb_items.TBItemsCharacter, key="User", location=self.testroom)
self.test_healpotion = create_object(key="healing potion")
self.test_healpotion.db.item_func = "heal"
self.test_healpotion.db.item_uses = 3
def tearDown(self):
super(TestTurnBattleItemsFunc, self).tearDown()
self.attacker.delete()
self.defender.delete()
self.joiner.delete()
self.user.delete()
self.testroom.delete()
self.turnhandler.stop()
# Test functions in tb_items.
def test_tbitemsfunc(self):
# Initiative roll
initiative = tb_items.roll_init(self.attacker)
self.assertTrue(initiative >= 0 and initiative <= 1000)
# Attack roll
attack_roll = tb_items.get_attack(self.attacker, self.defender)
self.assertTrue(attack_roll >= 0 and attack_roll <= 100)
# Defense roll
defense_roll = tb_items.get_defense(self.attacker, self.defender)
self.assertTrue(defense_roll == 50)
# Damage roll
damage_roll = tb_items.get_damage(self.attacker, self.defender)
self.assertTrue(damage_roll >= 15 and damage_roll <= 25)
# Apply damage
self.defender.db.hp = 10
tb_items.apply_damage(self.defender, 3)
self.assertTrue(self.defender.db.hp == 7)
# Resolve attack
self.defender.db.hp = 40
tb_items.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10)
self.assertTrue(self.defender.db.hp < 40)
# Combat cleanup
self.attacker.db.Combat_attribute = True
tb_items.combat_cleanup(self.attacker)
self.assertFalse(self.attacker.db.combat_attribute)
# Is in combat
self.assertFalse(tb_items.is_in_combat(self.attacker))
# Set up turn handler script for further tests
self.attacker.location.scripts.add(tb_items.TBItemsTurnHandler)
self.turnhandler = self.attacker.db.combat_TurnHandler
self.assertTrue(self.attacker.db.combat_TurnHandler)
# Set the turn handler's interval very high to keep it from repeating during tests.
self.turnhandler.interval = 10000
# Force turn order
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
# Test is turn
self.assertTrue(tb_items.is_turn(self.attacker))
# Spend actions
self.attacker.db.Combat_ActionsLeft = 1
tb_items.spend_action(self.attacker, 1, action_name="Test")
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
self.assertTrue(self.attacker.db.Combat_LastAction == "Test")
# Initialize for combat
self.attacker.db.Combat_ActionsLeft = 983
self.turnhandler.initialize_for_combat(self.attacker)
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
self.assertTrue(self.attacker.db.Combat_LastAction == "null")
# Start turn
self.defender.db.Combat_ActionsLeft = 0
self.turnhandler.start_turn(self.defender)
self.assertTrue(self.defender.db.Combat_ActionsLeft == 1)
# Next turn
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
self.turnhandler.next_turn()
self.assertTrue(self.turnhandler.db.turn == 1)
# Turn end check
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
self.attacker.db.Combat_ActionsLeft = 0
self.turnhandler.turn_end_check(self.attacker)
self.assertTrue(self.turnhandler.db.turn == 1)
# Join fight
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
self.turnhandler.join_fight(self.joiner)
self.assertTrue(self.turnhandler.db.turn == 1)
self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender])
# Now time to test item stuff.
# Spend item use
tb_items.spend_item_use(self.test_healpotion, self.user)
self.assertTrue(self.test_healpotion.db.item_uses == 2)
# Use item
self.user.db.hp = 2
tb_items.use_item(self.user, self.test_healpotion, self.user)
self.assertTrue(self.user.db.hp > 2)
# Add contition
tb_items.add_condition(self.user, self.user, "Test", 5)
self.assertTrue(self.user.db.conditions == {"Test":[5, self.user]})
# Condition tickdown
tb_items.condition_tickdown(self.user, self.user)
self.assertTrue(self.user.db.conditions == {"Test":[4, self.user]})
# Test item functions now!
# Item heal
self.user.db.hp = 2
tb_items.itemfunc_heal(self.test_healpotion, self.user, self.user)
# Item add condition
self.user.db.conditions = {}
tb_items.itemfunc_add_condition(self.test_healpotion, self.user, self.user)
self.assertTrue(self.user.db.conditions == {"Regeneration":[5, self.user]})
# Item cure condition
self.user.db.conditions = {"Poisoned":[5, self.user]}
tb_items.itemfunc_cure_condition(self.test_healpotion, self.user, self.user)
self.assertTrue(self.user.db.conditions == {})
class TestTurnBattleMagicFunc(EvenniaTest):
def setUp(self):
super(TestTurnBattleMagicFunc, self).setUp()
self.testroom = create_object(DefaultRoom, key="Test Room")
self.attacker = create_object(tb_magic.TBMagicCharacter, key="Attacker", location=self.testroom)
self.defender = create_object(tb_magic.TBMagicCharacter, key="Defender", location=self.testroom)
self.joiner = create_object(tb_magic.TBMagicCharacter, key="Joiner", location=self.testroom)
def tearDown(self):
super(TestTurnBattleMagicFunc, self).tearDown()
self.attacker.delete()
self.defender.delete()
self.joiner.delete()
self.testroom.delete()
self.turnhandler.stop()
# Test combat functions in tb_magic.
def test_tbbasicfunc(self):
# Initiative roll
initiative = tb_magic.roll_init(self.attacker)
self.assertTrue(initiative >= 0 and initiative <= 1000)
# Attack roll
attack_roll = tb_magic.get_attack(self.attacker, self.defender)
self.assertTrue(attack_roll >= 0 and attack_roll <= 100)
# Defense roll
defense_roll = tb_magic.get_defense(self.attacker, self.defender)
self.assertTrue(defense_roll == 50)
# Damage roll
damage_roll = tb_magic.get_damage(self.attacker, self.defender)
self.assertTrue(damage_roll >= 15 and damage_roll <= 25)
# Apply damage
self.defender.db.hp = 10
tb_magic.apply_damage(self.defender, 3)
self.assertTrue(self.defender.db.hp == 7)
# Resolve attack
self.defender.db.hp = 40
tb_magic.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10)
self.assertTrue(self.defender.db.hp < 40)
# Combat cleanup
self.attacker.db.Combat_attribute = True
tb_magic.combat_cleanup(self.attacker)
self.assertFalse(self.attacker.db.combat_attribute)
# Is in combat
self.assertFalse(tb_magic.is_in_combat(self.attacker))
# Set up turn handler script for further tests
self.attacker.location.scripts.add(tb_magic.TBMagicTurnHandler)
self.turnhandler = self.attacker.db.combat_TurnHandler
self.assertTrue(self.attacker.db.combat_TurnHandler)
# Set the turn handler's interval very high to keep it from repeating during tests.
self.turnhandler.interval = 10000
# Force turn order
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
# Test is turn
self.assertTrue(tb_magic.is_turn(self.attacker))
# Spend actions
self.attacker.db.Combat_ActionsLeft = 1
tb_magic.spend_action(self.attacker, 1, action_name="Test")
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
self.assertTrue(self.attacker.db.Combat_LastAction == "Test")
# Initialize for combat
self.attacker.db.Combat_ActionsLeft = 983
self.turnhandler.initialize_for_combat(self.attacker)
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
self.assertTrue(self.attacker.db.Combat_LastAction == "null")
# Start turn
self.defender.db.Combat_ActionsLeft = 0
self.turnhandler.start_turn(self.defender)
self.assertTrue(self.defender.db.Combat_ActionsLeft == 1)
# Next turn
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
self.turnhandler.next_turn()
self.assertTrue(self.turnhandler.db.turn == 1)
# Turn end check
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
self.attacker.db.Combat_ActionsLeft = 0
self.turnhandler.turn_end_check(self.attacker)
self.assertTrue(self.turnhandler.db.turn == 1)
# Join fight
self.turnhandler.db.fighters = [self.attacker, self.defender]
self.turnhandler.db.turn = 0
self.turnhandler.join_fight(self.joiner)
self.assertTrue(self.turnhandler.db.turn == 1)
self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender])
# Test tree select
from evennia.contrib import tree_select
TREE_MENU_TESTSTR = """Foo
Bar
-Baz
--Baz 1
--Baz 2
-Qux"""
class TestTreeSelectFunc(EvenniaTest):
def test_tree_functions(self):
# Dash counter
self.assertTrue(tree_select.dashcount("--test") == 2)
# Is category
self.assertTrue(tree_select.is_category(TREE_MENU_TESTSTR, 1) == True)
# Parse options
self.assertTrue(tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2) == [(3, "Baz 1"), (4, "Baz 2")])
# Index to selection
self.assertTrue(tree_select.index_to_selection(TREE_MENU_TESTSTR, 2) == "Baz")
# Go up one category
self.assertTrue(tree_select.go_up_one_category(TREE_MENU_TESTSTR, 4) == 2)
# Option list to menu options
test_optlist = tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2)
optlist_to_menu_expected_result = [{'goto': ['menunode_treeselect', {'newindex': 3}], 'key': 'Baz 1'},
{'goto': ['menunode_treeselect', {'newindex': 4}], 'key': 'Baz 2'},
{'goto': ['menunode_treeselect', {'newindex': 1}], 'key': ['<< Go Back', 'go back', 'back'], 'desc': 'Return to the previous menu.'}]
self.assertTrue(tree_select.optlist_to_menuoptions(TREE_MENU_TESTSTR, test_optlist, 2, True, True) == optlist_to_menu_expected_result)
# Test field fill
from evennia.contrib import fieldfill
FIELD_TEST_TEMPLATE = [
{"fieldname":"TextTest", "fieldtype":"text"},
{"fieldname":"NumberTest", "fieldtype":"number", "blankmsg":"Number here!"},
{"fieldname":"DefaultText", "fieldtype":"text", "default":"Test"},
{"fieldname":"DefaultNum", "fieldtype":"number", "default":3}
]
FIELD_TEST_DATA = {"TextTest":None, "NumberTest":None, "DefaultText":"Test", "DefaultNum":3}
class TestFieldFillFunc(EvenniaTest):
def test_field_functions(self):
self.assertTrue(fieldfill.form_template_to_dict(FIELD_TEST_TEMPLATE) == FIELD_TEST_DATA)
# Test of the unixcommand module
from evennia.contrib.unixcommand import UnixCommand
@ -1850,3 +2395,148 @@ class TestPuzzles(CommandTest):
'pasta': 2,
'boiled egg': 1,
})
# Tests for the building_menu contrib
from evennia.contrib.building_menu import BuildingMenu, CmdNoInput, CmdNoMatch
class Submenu(BuildingMenu):
def init(self, exit):
self.add_choice("title", key="t", attr="key")
class TestBuildingMenu(CommandTest):
def setUp(self):
super(TestBuildingMenu, self).setUp()
self.menu = BuildingMenu(caller=self.char1, obj=self.room1, title="test")
self.menu.add_choice("title", key="t", attr="key")
def test_quit(self):
"""Try to quit the building menu."""
self.assertFalse(self.char1.cmdset.has("building_menu"))
self.menu.open()
self.assertTrue(self.char1.cmdset.has("building_menu"))
self.call(CmdNoMatch(building_menu=self.menu), "q")
# char1 tries to quit the editor
self.assertFalse(self.char1.cmdset.has("building_menu"))
def test_setattr(self):
"""Test the simple setattr provided by building menus."""
key = self.room1.key
self.menu.open()
self.call(CmdNoMatch(building_menu=self.menu), "t")
self.assertIsNotNone(self.menu.current_choice)
self.call(CmdNoMatch(building_menu=self.menu), "some new title")
self.call(CmdNoMatch(building_menu=self.menu), "@")
self.assertIsNone(self.menu.current_choice)
self.assertEqual(self.room1.key, "some new title")
self.call(CmdNoMatch(building_menu=self.menu), "q")
def test_add_choice_without_key(self):
"""Try to add choices without keys."""
choices = []
for i in range(20):
choices.append(self.menu.add_choice("choice", attr="test"))
self.menu._add_keys_choice()
keys = ["c", "h", "o", "i", "e", "ch", "ho", "oi", "ic", "ce", "cho", "hoi", "oic", "ice", "choi", "hoic", "oice", "choic", "hoice", "choice"]
for i in range(20):
self.assertEqual(choices[i].key, keys[i])
# Adding another key of the same title would break, no more available shortcut
self.menu.add_choice("choice", attr="test")
with self.assertRaises(ValueError):
self.menu._add_keys_choice()
def test_callbacks(self):
"""Test callbacks in menus."""
self.room1.key = "room1"
def on_enter(caller, menu):
caller.msg("on_enter:{}".format(menu.title))
def on_nomatch(caller, string, choice):
caller.msg("on_nomatch:{},{}".format(string, choice.key))
def on_leave(caller, obj):
caller.msg("on_leave:{}".format(obj.key))
self.menu.add_choice("test", key="e", on_enter=on_enter, on_nomatch=on_nomatch, on_leave=on_leave)
self.call(CmdNoMatch(building_menu=self.menu), "e", "on_enter:test")
self.call(CmdNoMatch(building_menu=self.menu), "ok", "on_nomatch:ok,e")
self.call(CmdNoMatch(building_menu=self.menu), "@", "on_leave:room1")
self.call(CmdNoMatch(building_menu=self.menu), "q")
def test_multi_level(self):
"""Test multi-level choices."""
# Creaste three succeeding menu (t2 is contained in t1, t3 is contained in t2)
def on_nomatch_t1(caller, menu):
menu.move("whatever") # this will be valid since after t1 is a joker
def on_nomatch_t2(caller, menu):
menu.move("t3") # this time the key matters
t1 = self.menu.add_choice("what", key="t1", on_nomatch=on_nomatch_t1)
t2 = self.menu.add_choice("and", key="t1.*", on_nomatch=on_nomatch_t2)
t3 = self.menu.add_choice("why", key="t1.*.t3")
self.menu.open()
# Move into t1
self.assertIn(t1, self.menu.relevant_choices)
self.assertNotIn(t2, self.menu.relevant_choices)
self.assertNotIn(t3, self.menu.relevant_choices)
self.assertIsNone(self.menu.current_choice)
self.call(CmdNoMatch(building_menu=self.menu), "t1")
self.assertEqual(self.menu.current_choice, t1)
self.assertNotIn(t1, self.menu.relevant_choices)
self.assertIn(t2, self.menu.relevant_choices)
self.assertNotIn(t3, self.menu.relevant_choices)
# Move into t2
self.call(CmdNoMatch(building_menu=self.menu), "t2")
self.assertEqual(self.menu.current_choice, t2)
self.assertNotIn(t1, self.menu.relevant_choices)
self.assertNotIn(t2, self.menu.relevant_choices)
self.assertIn(t3, self.menu.relevant_choices)
# Move into t3
self.call(CmdNoMatch(building_menu=self.menu), "t3")
self.assertEqual(self.menu.current_choice, t3)
self.assertNotIn(t1, self.menu.relevant_choices)
self.assertNotIn(t2, self.menu.relevant_choices)
self.assertNotIn(t3, self.menu.relevant_choices)
# Move back to t2
self.call(CmdNoMatch(building_menu=self.menu), "@")
self.assertEqual(self.menu.current_choice, t2)
self.assertNotIn(t1, self.menu.relevant_choices)
self.assertNotIn(t2, self.menu.relevant_choices)
self.assertIn(t3, self.menu.relevant_choices)
# Move back into t1
self.call(CmdNoMatch(building_menu=self.menu), "@")
self.assertEqual(self.menu.current_choice, t1)
self.assertNotIn(t1, self.menu.relevant_choices)
self.assertIn(t2, self.menu.relevant_choices)
self.assertNotIn(t3, self.menu.relevant_choices)
# Moves back to the main menu
self.call(CmdNoMatch(building_menu=self.menu), "@")
self.assertIn(t1, self.menu.relevant_choices)
self.assertNotIn(t2, self.menu.relevant_choices)
self.assertNotIn(t3, self.menu.relevant_choices)
self.assertIsNone(self.menu.current_choice)
self.call(CmdNoMatch(building_menu=self.menu), "q")
def test_submenu(self):
"""Test to add sub-menus."""
def open_exit(menu):
menu.open_submenu("evennia.contrib.tests.Submenu", self.exit)
return False
self.menu.add_choice("exit", key="x", on_enter=open_exit)
self.menu.open()
self.call(CmdNoMatch(building_menu=self.menu), "x")
self.menu = self.char1.ndb._building_menu
self.call(CmdNoMatch(building_menu=self.menu), "t")
self.call(CmdNoMatch(building_menu=self.menu), "in")
self.call(CmdNoMatch(building_menu=self.menu), "@")
self.call(CmdNoMatch(building_menu=self.menu), "@")
self.menu = self.char1.ndb._building_menu
self.assertEqual(self.char1.ndb._building_menu.obj, self.room1)
self.call(CmdNoMatch(building_menu=self.menu), "q")
self.assertEqual(self.exit.key, "in")

View file

@ -0,0 +1,535 @@
"""
Easy menu selection tree
Contrib - Tim Ashley Jenkins 2017
This module allows you to create and initialize an entire branching EvMenu
instance with nothing but a multi-line string passed to one function.
EvMenu is incredibly powerful and flexible, but using it for simple menus
can often be fairly cumbersome - a simple menu that can branch into five
categories would require six nodes, each with options represented as a list
of dictionaries.
This module provides a function, init_tree_selection, which acts as a frontend
for EvMenu, dynamically sourcing the options from a multi-line string you provide.
For example, if you define a string as such:
TEST_MENU = '''Foo
Bar
Baz
Qux'''
And then use TEST_MENU as the 'treestr' source when you call init_tree_selection
on a player:
init_tree_selection(TEST_MENU, caller, callback)
The player will be presented with an EvMenu, like so:
___________________________
Make your selection:
___________________________
Foo
Bar
Baz
Qux
Making a selection will pass the selection's key to the specified callback as a
string along with the caller, as well as the index of the selection (the line number
on the source string) along with the source string for the tree itself.
In addition to specifying selections on the menu, you can also specify categories.
Categories are indicated by putting options below it preceded with a '-' character.
If a selection is a category, then choosing it will bring up a new menu node, prompting
the player to select between those options, or to go back to the previous menu. In
addition, categories are marked by default with a '[+]' at the end of their key. Both
this marker and the option to go back can be disabled.
Categories can be nested in other categories as well - just go another '-' deeper. You
can do this as many times as you like. There's no hard limit to the number of
categories you can go down.
For example, let's add some more options to our menu, turning 'Bar' into a category.
TEST_MENU = '''Foo
Bar
-You've got to know
--When to hold em
--When to fold em
--When to walk away
Baz
Qux'''
Now when we call the menu, we can see that 'Bar' has become a category instead of a
selectable option.
_______________________________
Make your selection:
_______________________________
Foo
Bar [+]
Baz
Qux
Note the [+] next to 'Bar'. If we select 'Bar', it'll show us the option listed under it.
________________________________________________________________
Bar
________________________________________________________________
You've got to know [+]
<< Go Back: Return to the previous menu.
Just the one option, which is a category itself, and the option to go back, which will
take us back to the previous menu. Let's select 'You've got to know'.
________________________________________________________________
You've got to know
________________________________________________________________
When to hold em
When to fold em
When to walk away
<< Go Back: Return to the previous menu.
Now we see the three options listed under it, too. We can select one of them or use 'Go
Back' to return to the 'Bar' menu we were just at before. It's very simple to make a
branching tree of selections!
One last thing - you can set the descriptions for the various options simply by adding a
':' character followed by the description to the option's line. For example, let's add a
description to 'Baz' in our menu:
TEST_MENU = '''Foo
Bar
-You've got to know
--When to hold em
--When to fold em
--When to walk away
Baz: Look at this one: the best option.
Qux'''
Now we see that the Baz option has a description attached that's separate from its key:
_______________________________________________________________
Make your selection:
_______________________________________________________________
Foo
Bar [+]
Baz: Look at this one: the best option.
Qux
Once the player makes a selection - let's say, 'Foo' - the menu will terminate and call
your specified callback with the selection, like so:
callback(caller, TEST_MENU, 0, "Foo")
The index of the selection is given along with a string containing the selection's key.
That way, if you have two selections in the menu with the same key, you can still
differentiate between them.
And that's all there is to it! For simple branching-tree selections, using this system is
much easier than manually creating EvMenu nodes. It also makes generating menus with dynamic
options much easier - since the source of the menu tree is just a string, you could easily
generate that string procedurally before passing it to the init_tree_selection function.
For example, if a player casts a spell or does an attack without specifying a target, instead
of giving them an error, you could present them with a list of valid targets to select by
generating a multi-line string of targets and passing it to init_tree_selection, with the
callable performing the maneuver once a selection is made.
This selection system only works for simple branching trees - doing anything really complicated
like jumping between categories or prompting for arbitrary input would still require a full
EvMenu implementation. For simple selections, however, I'm sure you will find using this function
to be much easier!
Included in this module is a sample menu and function which will let a player change the color
of their name - feel free to mess with it to get a feel for how this system works by importing
this module in your game's default_cmdsets.py module and adding CmdNameColor to your default
character's command set.
"""
from evennia.utils import evmenu
from evennia.utils.logger import log_trace
from evennia import Command
def init_tree_selection(treestr, caller, callback,
index=None, mark_category=True, go_back=True,
cmd_on_exit="look",
start_text="Make your selection:"):
"""
Prompts a player to select an option from a menu tree given as a multi-line string.
Args:
treestr (str): Multi-lne string representing menu options
caller (obj): Player to initialize the menu for
callback (callable): Function to run when a selection is made. Must take 4 args:
caller (obj): Caller given above
treestr (str): Menu tree string given above
index (int): Index of final selection
selection (str): Key of final selection
Options:
index (int or None): Index to start the menu at, or None for top level
mark_category (bool): If True, marks categories with a [+] symbol in the menu
go_back (bool): If True, present an option to go back to previous categories
start_text (str): Text to display at the top level of the menu
cmd_on_exit(str): Command to enter when the menu exits - 'look' by default
Notes:
This function will initialize an instance of EvMenu with options generated
dynamically from the source string, and passes the menu user's selection to
a function of your choosing. The EvMenu is made of a single, repeating node,
which will call itself over and over at different levels of the menu tree as
categories are selected.
Once a non-category selection is made, the user's selection will be passed to
the given callable, both as a string and as an index number. The index is given
to ensure every selection has a unique identifier, so that selections with the
same key in different categories can be distinguished between.
The menus called by this function are not persistent and cannot perform
complicated tasks like prompt for arbitrary input or jump multiple category
levels at once - you'll have to use EvMenu itself if you want to take full
advantage of its features.
"""
# Pass kwargs to store data needed in the menu
kwargs = {
"index":index,
"mark_category":mark_category,
"go_back":go_back,
"treestr":treestr,
"callback":callback,
"start_text":start_text
}
# Initialize menu of selections
evmenu.EvMenu(caller, "evennia.contrib.tree_select", startnode="menunode_treeselect",
startnode_input=None, cmd_on_exit=cmd_on_exit, **kwargs)
def dashcount(entry):
"""
Counts the number of dashes at the beginning of a string. This
is needed to determine the depth of options in categories.
Args:
entry (str): String to count the dashes at the start of
Returns:
dashes (int): Number of dashes at the start
"""
dashes = 0
for char in entry:
if char == "-":
dashes += 1
else:
return dashes
return dashes
def is_category(treestr, index):
"""
Determines whether an option in a tree string is a category by
whether or not there are additional options below it.
Args:
treestr (str): Multi-line string representing menu options
index (int): Which line of the string to test
Returns:
is_category (bool): Whether the option is a category
"""
opt_list = treestr.split('\n')
# Not a category if it's the last one in the list
if index == len(opt_list) - 1:
return False
# Not a category if next option is not one level deeper
return not bool(dashcount(opt_list[index+1]) != dashcount(opt_list[index]) + 1)
def parse_opts(treestr, category_index=None):
"""
Parses a tree string and given index into a list of options. If
category_index is none, returns all the options at the top level of
the menu. If category_index corresponds to a category, returns a list
of options under that category. If category_index corresponds to
an option that is not a category, it's a selection and returns True.
Args:
treestr (str): Multi-line string representing menu options
category_index (int): Index of category or None for top level
Returns:
kept_opts (list or True): Either a list of options in the selected
category or True if a selection was made
"""
dash_depth = 0
opt_list = treestr.split('\n')
kept_opts = []
# If a category index is given
if category_index != None:
# If given index is not a category, it's a selection - return True.
if not is_category(treestr, category_index):
return True
# Otherwise, change the dash depth to match the new category.
dash_depth = dashcount(opt_list[category_index]) + 1
# Delete everything before the category index
opt_list = opt_list [category_index+1:]
# Keep every option (referenced by index) at the appropriate depth
cur_index = 0
for option in opt_list:
if dashcount(option) == dash_depth:
if category_index == None:
kept_opts.append((cur_index, option[dash_depth:]))
else:
kept_opts.append((cur_index + category_index + 1, option[dash_depth:]))
# Exits the loop if leaving a category
if dashcount(option) < dash_depth:
return kept_opts
cur_index += 1
return kept_opts
def index_to_selection(treestr, index, desc=False):
"""
Given a menu tree string and an index, returns the corresponding selection's
name as a string. If 'desc' is set to True, will return the selection's
description as a string instead.
Args:
treestr (str): Multi-line string representing menu options
index (int): Index to convert to selection key or description
Options:
desc (bool): If true, returns description instead of key
Returns:
selection (str): Selection key or description if 'desc' is set
"""
opt_list = treestr.split('\n')
# Fetch the given line
selection = opt_list[index]
# Strip out the dashes at the start
selection = selection[dashcount(selection):]
# Separate out description, if any
if ":" in selection:
# Split string into key and description
selection = selection.split(':', 1)
selection[1] = selection[1].strip(" ")
else:
# If no description given, set description to None
selection = [selection, None]
if not desc:
return selection[0]
else:
return selection[1]
def go_up_one_category(treestr, index):
"""
Given a menu tree string and an index, returns the category that the given option
belongs to. Used for the 'go back' option.
Args:
treestr (str): Multi-line string representing menu options
index (int): Index to determine the parent category of
Returns:
parent_category (int): Index of parent category
"""
opt_list = treestr.split('\n')
# Get the number of dashes deep the given index is
dash_level = dashcount(opt_list[index])
# Delete everything after the current index
opt_list = opt_list[:index+1]
# If there's no dash, return 'None' to return to base menu
if dash_level == 0:
return None
current_index = index
# Go up through each option until we find one that's one category above
for selection in reversed(opt_list):
if dashcount(selection) == dash_level - 1:
return current_index
current_index -= 1
def optlist_to_menuoptions(treestr, optlist, index, mark_category, go_back):
"""
Takes a list of options processed by parse_opts and turns it into
a list/dictionary of menu options for use in menunode_treeselect.
Args:
treestr (str): Multi-line string representing menu options
optlist (list): List of options to convert to EvMenu's option format
index (int): Index of current category
mark_category (bool): Whether or not to mark categories with [+]
go_back (bool): Whether or not to add an option to go back in the menu
Returns:
menuoptions (list of dicts): List of menu options formatted for use
in EvMenu, each passing a different "newindex" kwarg that changes
the menu level or makes a selection
"""
menuoptions = []
cur_index = 0
for option in optlist:
index_to_add = optlist[cur_index][0]
menuitem = {}
keystr = index_to_selection(treestr, index_to_add)
if mark_category and is_category(treestr, index_to_add):
# Add the [+] to the key if marking categories, and the key by itself as an alias
menuitem["key"] = [keystr + " [+]", keystr]
else:
menuitem["key"] = keystr
# Get the option's description
desc = index_to_selection(treestr, index_to_add, desc=True)
if desc:
menuitem["desc"] = desc
# Passing 'newindex' as a kwarg to the node is how we move through the menu!
menuitem["goto"] = ["menunode_treeselect", {"newindex":index_to_add}]
menuoptions.append(menuitem)
cur_index += 1
# Add option to go back, if needed
if index != None and go_back == True:
gobackitem = {"key":["<< Go Back", "go back", "back"],
"desc":"Return to the previous menu.",
"goto":["menunode_treeselect", {"newindex":go_up_one_category(treestr, index)}]}
menuoptions.append(gobackitem)
return menuoptions
def menunode_treeselect(caller, raw_string, **kwargs):
"""
This is the repeating menu node that handles the tree selection.
"""
# If 'newindex' is in the kwargs, change the stored index.
if "newindex" in kwargs:
caller.ndb._menutree.index = kwargs["newindex"]
# Retrieve menu info
index = caller.ndb._menutree.index
mark_category = caller.ndb._menutree.mark_category
go_back = caller.ndb._menutree.go_back
treestr = caller.ndb._menutree.treestr
callback = caller.ndb._menutree.callback
start_text = caller.ndb._menutree.start_text
# List of options if index is 'None' or category, or 'True' if a selection
optlist = parse_opts(treestr, category_index=index)
# If given index returns optlist as 'True', it's a selection. Pass to callback and end the menu.
if optlist == True:
selection = index_to_selection(treestr, index)
try:
callback(caller, treestr, index, selection)
except Exception:
log_trace("Error in tree selection callback.")
# Returning None, None ends the menu.
return None, None
# Otherwise, convert optlist to a list of menu options.
else:
options = optlist_to_menuoptions(treestr, optlist, index, mark_category, go_back)
if index == None:
# Use start_text for the menu text on the top level
text = start_text
else:
# Use the category name and description (if any) as the menu text
if index_to_selection(treestr, index, desc=True) != None:
text = "|w" + index_to_selection(treestr, index) + "|n: " + index_to_selection(treestr, index, desc=True)
else:
text = "|w" + index_to_selection(treestr, index) + "|n"
return text, options
# The rest of this module is for the example menu and command! It'll change the color of your name.
"""
Here's an example string that you can initialize a menu from. Note the dashes at
the beginning of each line - that's how menu option depth and hierarchy is determined.
"""
NAMECOLOR_MENU = """Set name color: Choose a color for your name!
-Red shades: Various shades of |511red|n
--Red: |511Set your name to Red|n
--Pink: |533Set your name to Pink|n
--Maroon: |301Set your name to Maroon|n
-Orange shades: Various shades of |531orange|n
--Orange: |531Set your name to Orange|n
--Brown: |321Set your name to Brown|n
--Sienna: |420Set your name to Sienna|n
-Yellow shades: Various shades of |551yellow|n
--Yellow: |551Set your name to Yellow|n
--Gold: |540Set your name to Gold|n
--Dandelion: |553Set your name to Dandelion|n
-Green shades: Various shades of |141green|n
--Green: |141Set your name to Green|n
--Lime: |350Set your name to Lime|n
--Forest: |032Set your name to Forest|n
-Blue shades: Various shades of |115blue|n
--Blue: |115Set your name to Blue|n
--Cyan: |155Set your name to Cyan|n
--Navy: |113Set your name to Navy|n
-Purple shades: Various shades of |415purple|n
--Purple: |415Set your name to Purple|n
--Lavender: |535Set your name to Lavender|n
--Fuchsia: |503Set your name to Fuchsia|n
Remove name color: Remove your name color, if any"""
class CmdNameColor(Command):
"""
Set or remove a special color on your name. Just an example for the
easy menu selection tree contrib.
"""
key = "namecolor"
def func(self):
# This is all you have to do to initialize a menu!
init_tree_selection(NAMECOLOR_MENU, self.caller,
change_name_color,
start_text="Name color options:")
def change_name_color(caller, treestr, index, selection):
"""
Changes a player's name color.
Args:
caller (obj): Character whose name to color.
treestr (str): String for the color change menu - unused
index (int): Index of menu selection - unused
selection (str): Selection made from the name color menu - used
to determine the color the player chose.
"""
# Store the caller's uncolored name
if not caller.db.uncolored_name:
caller.db.uncolored_name = caller.key
# Dictionary matching color selection names to color codes
colordict = { "Red":"|511", "Pink":"|533", "Maroon":"|301",
"Orange":"|531", "Brown":"|321", "Sienna":"|420",
"Yellow":"|551", "Gold":"|540", "Dandelion":"|553",
"Green":"|141", "Lime":"|350", "Forest":"|032",
"Blue":"|115", "Cyan":"|155", "Navy":"|113",
"Purple":"|415", "Lavender":"|535", "Fuchsia":"|503"}
# I know this probably isn't the best way to do this. It's just an example!
if selection == "Remove name color": # Player chose to remove their name color
caller.key = caller.db.uncolored_name
caller.msg("Name color removed.")
elif selection in colordict:
newcolor = colordict[selection] # Retrieve color code based on menu selection
caller.key = newcolor + caller.db.uncolored_name + "|n" # Add color code to caller's name
caller.msg(newcolor + ("Name color changed to %s!" % selection) + "|n")

View file

@ -0,0 +1,55 @@
# Turn based battle system framework
Contrib - Tim Ashley Jenkins 2017
This is a framework for a simple turn-based combat system, similar
to those used in D&D-style tabletop role playing games. It allows
any character to start a fight in a room, at which point initiative
is rolled and a turn order is established. Each participant in combat
has a limited time to decide their action for that turn (30 seconds by
default), and combat progresses through the turn order, looping through
the participants until the fight ends.
This folder contains multiple examples of how such a system can be
implemented and customized:
tb_basic.py - The simplest system, which implements initiative and turn
order, attack rolls against defense values, and damage to hit
points. Only very basic game mechanics are included.
tb_equip.py - Adds weapons and armor to the basic implementation of
the battle system, including commands for wielding weapons and
donning armor, and modifiers to accuracy and damage based on
currently used equipment.
tb_items.py - Adds usable items and conditions/status effects, and gives
a lot of examples for each. Items can perform nearly any sort of
function, including healing, adding or curing conditions, or
being used to attack. Conditions affect a fighter's attributes
and options in combat and persist outside of fights, counting
down per turn in combat and in real time outside combat.
tb_magic.py - Adds a spellcasting system, allowing characters to cast
spells with a variety of effects by spending MP. Spells are
linked to functions, and as such can perform any sort of action
the developer can imagine - spells for attacking, healing and
conjuring objects are included as examples.
tb_range.py - Adds a system for abstract positioning and movement, which
tracks the distance between different characters and objects in
combat, as well as differentiates between melee and ranged
attacks.
This system is meant as a basic framework to start from, and is modeled
after the combat systems of popular tabletop role playing games rather than
the real-time battle systems that many MMOs and some MUDs use. As such, it
may be better suited to role-playing or more story-oriented games, or games
meant to closely emulate the experience of playing a tabletop RPG.
Each of these modules contains the full functionality of the battle system
with different customizations added in - the instructions to install each
one is contained in the module itself. It's recommended that you install
and test tb_basic first, so you can better understand how the other
modules expand on it and get a better idea of how you can customize the
system to your liking and integrate the subsystems presented here into
your own combat system.

View file

@ -0,0 +1 @@

View file

@ -16,26 +16,26 @@ is easily extensible and can be used as the foundation for implementing
the rules from your turn-based tabletop game of choice or making your
own battle system.
To install and test, import this module's BattleCharacter object into
To install and test, import this module's TBBasicCharacter object into
your game's character.py module:
from evennia.contrib.turnbattle import BattleCharacter
from evennia.contrib.turnbattle.tb_basic import TBBasicCharacter
And change your game's character typeclass to inherit from BattleCharacter
And change your game's character typeclass to inherit from TBBasicCharacter
instead of the default:
class Character(BattleCharacter):
class Character(TBBasicCharacter):
Next, import this module into your default_cmdsets.py module:
from evennia.contrib import turnbattle
from evennia.contrib.turnbattle import tb_basic
And add the battle command set to your default command set:
#
# any commands you add below will overload the default ones.
#
self.add(turnbattle.BattleCmdSet())
self.add(tb_basic.BattleCmdSet())
This module is meant to be heavily expanded on, so you may want to copy it
to your game's 'world' folder and modify it there rather than importing it
@ -48,10 +48,18 @@ from evennia.commands.default.help import CmdHelp
"""
----------------------------------------------------------------------------
COMBAT FUNCTIONS START HERE
OPTIONS
----------------------------------------------------------------------------
"""
TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds
ACTIONS_PER_TURN = 1 # Number of actions allowed per turn
"""
----------------------------------------------------------------------------
COMBAT FUNCTIONS START HERE
----------------------------------------------------------------------------
"""
def roll_init(character):
"""
@ -167,6 +175,20 @@ def apply_damage(defender, damage):
if defender.db.hp <= 0:
defender.db.hp = 0
def at_defeat(defeated):
"""
Announces the defeat of a fighter in combat.
Args:
defeated (obj): Fighter that's been defeated.
Notes:
All this does is announce a defeat message by default, but if you
want anything else to happen to defeated fighters (like putting them
into a dying state or something similar) then this is the place to
do it.
"""
defeated.location.msg_contents("%s has been defeated!" % defeated)
def resolve_attack(attacker, defender, attack_value=None, defense_value=None):
"""
@ -195,10 +217,9 @@ def resolve_attack(attacker, defender, attack_value=None, defense_value=None):
# Announce damage dealt and apply damage.
attacker.location.msg_contents("%s hits %s for %i damage!" % (attacker, defender, damage_value))
apply_damage(defender, damage_value)
# If defender HP is reduced to 0 or less, announce defeat.
# If defender HP is reduced to 0 or less, call at_defeat.
if defender.db.hp <= 0:
attacker.location.msg_contents("%s has been defeated!" % defender)
at_defeat(defender)
def combat_cleanup(character):
"""
@ -226,9 +247,7 @@ def is_in_combat(character):
Returns:
(bool): True if in combat or False if not in combat
"""
if character.db.Combat_TurnHandler:
return True
return False
return bool(character.db.combat_turnhandler)
def is_turn(character):
@ -241,11 +260,9 @@ def is_turn(character):
Returns:
(bool): True if it is their turn or False otherwise
"""
turnhandler = character.db.Combat_TurnHandler
turnhandler = character.db.combat_turnhandler
currentchar = turnhandler.db.fighters[turnhandler.db.turn]
if character == currentchar:
return True
return False
return bool(character == currentchar)
def spend_action(character, actions, action_name=None):
@ -261,14 +278,14 @@ def spend_action(character, actions, action_name=None):
combat to provided string
"""
if action_name:
character.db.Combat_LastAction = action_name
character.db.combat_lastaction = action_name
if actions == 'all': # If spending all actions
character.db.Combat_ActionsLeft = 0 # Set actions to 0
character.db.combat_actionsleft = 0 # Set actions to 0
else:
character.db.Combat_ActionsLeft -= actions # Use up actions.
if character.db.Combat_ActionsLeft < 0:
character.db.Combat_ActionsLeft = 0 # Can't have fewer than 0 actions
character.db.Combat_TurnHandler.turn_end_check(character) # Signal potential end of turn.
character.db.combat_actionsleft -= actions # Use up actions.
if character.db.combat_actionsleft < 0:
character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions
character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn.
"""
@ -278,7 +295,7 @@ CHARACTER TYPECLASS
"""
class BattleCharacter(DefaultCharacter):
class TBBasicCharacter(DefaultCharacter):
"""
A character able to participate in turn-based combat. Has attributes for current
and maximum HP, and access to combat commands.
@ -324,7 +341,182 @@ class BattleCharacter(DefaultCharacter):
return False
return True
"""
----------------------------------------------------------------------------
SCRIPTS START HERE
----------------------------------------------------------------------------
"""
class TBBasicTurnHandler(DefaultScript):
"""
This is the script that handles the progression of combat through turns.
On creation (when a fight is started) it adds all combat-ready characters
to its roster and then sorts them into a turn order. There can only be one
fight going on in a single room at a time, so the script is assigned to a
room as its object.
Fights persist until only one participant is left with any HP or all
remaining participants choose to end the combat with the 'disengage' command.
"""
def at_script_creation(self):
"""
Called once, when the script is created.
"""
self.key = "Combat Turn Handler"
self.interval = 5 # Once every 5 seconds
self.persistent = True
self.db.fighters = []
# Add all fighters in the room with at least 1 HP to the combat."
for thing in self.obj.contents:
if thing.db.hp:
self.db.fighters.append(thing)
# Initialize each fighter for combat
for fighter in self.db.fighters:
self.initialize_for_combat(fighter)
# Add a reference to this script to the room
self.obj.db.combat_turnhandler = self
# Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order.
# The initiative roll is determined by the roll_init function and can be customized easily.
ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True)
self.db.fighters = ordered_by_roll
# Announce the turn order.
self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters))
# Start first fighter's turn.
self.start_turn(self.db.fighters[0])
# Set up the current turn and turn timeout delay.
self.db.turn = 0
self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options
def at_stop(self):
"""
Called at script termination.
"""
for fighter in self.db.fighters:
combat_cleanup(fighter) # Clean up the combat attributes for every fighter.
self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location
def at_repeat(self):
"""
Called once every self.interval seconds.
"""
currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order.
self.db.timer -= self.interval # Count down the timer.
if self.db.timer <= 0:
# Force current character to disengage if timer runs out.
self.obj.msg_contents("%s's turn timed out!" % currentchar)
spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions.
return
elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left
# Warn the current character if they're about to time out.
currentchar.msg("WARNING: About to time out!")
self.db.timeout_warning_given = True
def initialize_for_combat(self, character):
"""
Prepares a character for combat when starting or entering a fight.
Args:
character (obj): Character to initialize for combat.
"""
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character
character.db.combat_lastaction = "null" # Track last action taken in combat
def start_turn(self, character):
"""
Readies a character for the start of their turn by replenishing their
available actions and notifying them that their turn has come up.
Args:
character (obj): Character to be readied.
Notes:
Here, you only get one action per turn, but you might want to allow more than
one per turn, or even grant a number of actions based on a character's
attributes. You can even add multiple different kinds of actions, I.E. actions
separated for movement, by adding "character.db.combat_movesleft = 3" or
something similar.
"""
character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions
# Prompt the character for their turn and give some information.
character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp)
def next_turn(self):
"""
Advances to the next character in the turn order.
"""
# Check to see if every character disengaged as their last action. If so, end combat.
disengage_check = True
for fighter in self.db.fighters:
if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage
disengage_check = False
if disengage_check: # All characters have disengaged
self.obj.msg_contents("All fighters have disengaged! Combat is over!")
self.stop() # Stop this script and end combat.
return
# Check to see if only one character is left standing. If so, end combat.
defeated_characters = 0
for fighter in self.db.fighters:
if fighter.db.HP == 0:
defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated)
if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated
for fighter in self.db.fighters:
if fighter.db.HP != 0:
LastStanding = fighter # Pick the one fighter left with HP remaining
self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding)
self.stop() # Stop this script and end combat.
return
# Cycle to the next turn.
currentchar = self.db.fighters[self.db.turn]
self.db.turn += 1 # Go to the next in the turn order.
if self.db.turn > len(self.db.fighters) - 1:
self.db.turn = 0 # Go back to the first in the turn order once you reach the end.
newchar = self.db.fighters[self.db.turn] # Note the new character
self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer.
self.db.timeout_warning_given = False # Reset the timeout warning.
self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar))
self.start_turn(newchar) # Start the new character's turn.
def turn_end_check(self, character):
"""
Tests to see if a character's turn is over, and cycles to the next turn if it is.
Args:
character (obj): Character to test for end of turn
"""
if not character.db.combat_actionsleft: # Character has no actions remaining
self.next_turn()
return
def join_fight(self, character):
"""
Adds a new character to a fight already in progress.
Args:
character (obj): Character to be added to the fight.
"""
# Inserts the fighter to the turn order, right behind whoever's turn it currently is.
self.db.fighters.insert(self.db.turn, character)
# Tick the turn counter forward one to compensate.
self.db.turn += 1
# Initialize the character like you do at the start.
self.initialize_for_combat(character)
"""
----------------------------------------------------------------------------
COMMANDS START HERE
@ -365,13 +557,13 @@ class CmdFight(Command):
if len(fighters) <= 1: # If you're the only able fighter in the room
self.caller.msg("There's nobody here to fight!")
return
if here.db.Combat_TurnHandler: # If there's already a fight going on...
if here.db.combat_turnhandler: # If there's already a fight going on...
here.msg_contents("%s joins the fight!" % self.caller)
here.db.Combat_TurnHandler.join_fight(self.caller) # Join the fight!
here.db.combat_turnhandler.join_fight(self.caller) # Join the fight!
return
here.msg_contents("%s starts a fight!" % self.caller)
# Add a turn handler script to the room, which starts combat.
here.scripts.add("contrib.turnbattle.TurnHandler")
here.scripts.add("contrib.turnbattle.tb_basic.TBBasicTurnHandler")
# Remember you'll have to change the path to the script if you copy this code to your own modules!
@ -559,177 +751,4 @@ class BattleCmdSet(default_cmds.CharacterCmdSet):
self.add(CmdRest())
self.add(CmdPass())
self.add(CmdDisengage())
self.add(CmdCombatHelp())
"""
----------------------------------------------------------------------------
SCRIPTS START HERE
----------------------------------------------------------------------------
"""
class TurnHandler(DefaultScript):
"""
This is the script that handles the progression of combat through turns.
On creation (when a fight is started) it adds all combat-ready characters
to its roster and then sorts them into a turn order. There can only be one
fight going on in a single room at a time, so the script is assigned to a
room as its object.
Fights persist until only one participant is left with any HP or all
remaining participants choose to end the combat with the 'disengage' command.
"""
def at_script_creation(self):
"""
Called once, when the script is created.
"""
self.key = "Combat Turn Handler"
self.interval = 5 # Once every 5 seconds
self.persistent = True
self.db.fighters = []
# Add all fighters in the room with at least 1 HP to the combat."
for object in self.obj.contents:
if object.db.hp:
self.db.fighters.append(object)
# Initialize each fighter for combat
for fighter in self.db.fighters:
self.initialize_for_combat(fighter)
# Add a reference to this script to the room
self.obj.db.Combat_TurnHandler = self
# Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order.
# The initiative roll is determined by the roll_init function and can be customized easily.
ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True)
self.db.fighters = ordered_by_roll
# Announce the turn order.
self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters))
# Set up the current turn and turn timeout delay.
self.db.turn = 0
self.db.timer = 30 # 30 seconds
def at_stop(self):
"""
Called at script termination.
"""
for fighter in self.db.fighters:
combat_cleanup(fighter) # Clean up the combat attributes for every fighter.
self.obj.db.Combat_TurnHandler = None # Remove reference to turn handler in location
def at_repeat(self):
"""
Called once every self.interval seconds.
"""
currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order.
self.db.timer -= self.interval # Count down the timer.
if self.db.timer <= 0:
# Force current character to disengage if timer runs out.
self.obj.msg_contents("%s's turn timed out!" % currentchar)
spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions.
return
elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left
# Warn the current character if they're about to time out.
currentchar.msg("WARNING: About to time out!")
self.db.timeout_warning_given = True
def initialize_for_combat(self, character):
"""
Prepares a character for combat when starting or entering a fight.
Args:
character (obj): Character to initialize for combat.
"""
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
character.db.Combat_ActionsLeft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
character.db.Combat_TurnHandler = self # Add a reference to this turn handler script to the character
character.db.Combat_LastAction = "null" # Track last action taken in combat
def start_turn(self, character):
"""
Readies a character for the start of their turn by replenishing their
available actions and notifying them that their turn has come up.
Args:
character (obj): Character to be readied.
Notes:
Here, you only get one action per turn, but you might want to allow more than
one per turn, or even grant a number of actions based on a character's
attributes. You can even add multiple different kinds of actions, I.E. actions
separated for movement, by adding "character.db.Combat_MovesLeft = 3" or
something similar.
"""
character.db.Combat_ActionsLeft = 1 # 1 action per turn.
# Prompt the character for their turn and give some information.
character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp)
def next_turn(self):
"""
Advances to the next character in the turn order.
"""
# Check to see if every character disengaged as their last action. If so, end combat.
disengage_check = True
for fighter in self.db.fighters:
if fighter.db.Combat_LastAction != "disengage": # If a character has done anything but disengage
disengage_check = False
if disengage_check: # All characters have disengaged
self.obj.msg_contents("All fighters have disengaged! Combat is over!")
self.stop() # Stop this script and end combat.
return
# Check to see if only one character is left standing. If so, end combat.
defeated_characters = 0
for fighter in self.db.fighters:
if fighter.db.HP == 0:
defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated)
if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated
for fighter in self.db.fighters:
if fighter.db.HP != 0:
LastStanding = fighter # Pick the one fighter left with HP remaining
self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding)
self.stop() # Stop this script and end combat.
return
# Cycle to the next turn.
currentchar = self.db.fighters[self.db.turn]
self.db.turn += 1 # Go to the next in the turn order.
if self.db.turn > len(self.db.fighters) - 1:
self.db.turn = 0 # Go back to the first in the turn order once you reach the end.
newchar = self.db.fighters[self.db.turn] # Note the new character
self.db.timer = 30 + self.time_until_next_repeat() # Reset the timer.
self.db.timeout_warning_given = False # Reset the timeout warning.
self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar))
self.start_turn(newchar) # Start the new character's turn.
def turn_end_check(self, character):
"""
Tests to see if a character's turn is over, and cycles to the next turn if it is.
Args:
character (obj): Character to test for end of turn
"""
if not character.db.Combat_ActionsLeft: # Character has no actions remaining
self.next_turn()
return
def join_fight(self, character):
"""
Adds a new character to a fight already in progress.
Args:
character (obj): Character to be added to the fight.
"""
# Inserts the fighter to the turn order, right behind whoever's turn it currently is.
self.db.fighters.insert(self.db.turn, character)
# Tick the turn counter forward one to compensate.
self.db.turn += 1
# Initialize the character like you do at the start.
self.initialize_for_combat(character)
self.add(CmdCombatHelp())

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -749,9 +749,9 @@ hole
to the ground together with the stone archway that once help it up.
#
# We lock the bridge exit for the mob, so it don't wander out on the bridge. Only
# traversing objects controlled by a player (i.e. Characters) may cross the bridge.
# traversing objects controlled by an account (i.e. Characters) may cross the bridge.
#
@lock bridge = traverse:has_player()
@lock bridge = traverse:has_account()
#------------------------------------------------------------
#
@ -997,7 +997,7 @@ mobon ghost
The stairs are worn by the age-old passage of feet.
#
# Lock the antechamber so the ghost cannot get in there.
@lock stairs down = traverse:has_player()
@lock stairs down = traverse:has_account()
#
# Go down
#

View file

@ -24,7 +24,7 @@ import random
from evennia import DefaultObject, DefaultExit, Command, CmdSet
from evennia.utils import search, delay
from evennia.utils.spawner import spawn
from evennia.prototypes.spawner import spawn
# -------------------------------------------------------------
#
@ -905,19 +905,19 @@ WEAPON_PROTOTYPES = {
"magic": False,
"desc": "A generic blade."},
"knife": {
"prototype": "weapon",
"prototype_parent": "weapon",
"aliases": "sword",
"key": "Kitchen knife",
"desc": "A rusty kitchen knife. Better than nothing.",
"damage": 3},
"dagger": {
"prototype": "knife",
"prototype_parent": "knife",
"key": "Rusty dagger",
"aliases": ["knife", "dagger"],
"desc": "A double-edged dagger with a nicked edge and a wooden handle.",
"hit": 0.25},
"sword": {
"prototype": "weapon",
"prototype_parent": "weapon",
"key": "Rusty sword",
"aliases": ["sword"],
"desc": "A rusty shortsword. It has a leather-wrapped handle covered i food grease.",
@ -925,28 +925,28 @@ WEAPON_PROTOTYPES = {
"damage": 5,
"parry": 0.5},
"club": {
"prototype": "weapon",
"prototype_parent": "weapon",
"key": "Club",
"desc": "A heavy wooden club, little more than a heavy branch.",
"hit": 0.4,
"damage": 6,
"parry": 0.2},
"axe": {
"prototype": "weapon",
"prototype_parent": "weapon",
"key": "Axe",
"desc": "A woodcutter's axe with a keen edge.",
"hit": 0.4,
"damage": 6,
"parry": 0.2},
"ornate longsword": {
"prototype": "sword",
"prototype_parent": "sword",
"key": "Ornate longsword",
"desc": "A fine longsword with some swirling patterns on the handle.",
"hit": 0.5,
"magic": True,
"damage": 5},
"warhammer": {
"prototype": "club",
"prototype_parent": "club",
"key": "Silver Warhammer",
"aliases": ["hammer", "warhammer", "war"],
"desc": "A heavy war hammer with silver ornaments. This huge weapon causes massive damage - if you can hit.",
@ -954,21 +954,21 @@ WEAPON_PROTOTYPES = {
"magic": True,
"damage": 8},
"rune axe": {
"prototype": "axe",
"prototype_parent": "axe",
"key": "Runeaxe",
"aliases": ["axe"],
"hit": 0.4,
"magic": True,
"damage": 6},
"thruning": {
"prototype": "ornate longsword",
"prototype_parent": "ornate longsword",
"key": "Broadsword named Thruning",
"desc": "This heavy bladed weapon is marked with the name 'Thruning'. It is very powerful in skilled hands.",
"hit": 0.6,
"parry": 0.6,
"damage": 7},
"slayer waraxe": {
"prototype": "rune axe",
"prototype_parent": "rune axe",
"key": "Slayer waraxe",
"aliases": ["waraxe", "war", "slayer"],
"desc": "A huge double-bladed axe marked with the runes for 'Slayer'."
@ -976,7 +976,7 @@ WEAPON_PROTOTYPES = {
"hit": 0.7,
"damage": 8},
"ghostblade": {
"prototype": "ornate longsword",
"prototype_parent": "ornate longsword",
"key": "The Ghostblade",
"aliases": ["blade", "ghost"],
"desc": "This massive sword is large as you are tall, yet seems to weigh almost nothing."
@ -985,7 +985,7 @@ WEAPON_PROTOTYPES = {
"parry": 0.8,
"damage": 10},
"hawkblade": {
"prototype": "ghostblade",
"prototype_parent": "ghostblade",
"key": "The Hawkblade",
"aliases": ["hawk", "blade"],
"desc": "The weapon of a long-dead heroine and a more civilized age,"

View file

@ -747,7 +747,7 @@ class CmdLookDark(Command):
"""
caller = self.caller
if random.random() < 0.8:
if random.random() < 0.75:
# we don't find anything
caller.msg(random.choice(DARK_MESSAGES))
else: