Resolve merge conflicts

This commit is contained in:
Griatch 2018-01-01 21:05:35 +01:00
commit 76555e6ca5
34 changed files with 4332 additions and 412 deletions

View file

@ -3,7 +3,7 @@
# Sept 2017:
Release of Evennia 0.7; upgrade to Django 1.11, change 'Player' to
'Account', rework the website template and a slew of other updates.
Info on what changed and how to migrat is found here:
Info on what changed and how to migrate is found here:
https://groups.google.com/forum/#!msg/evennia/0JYYNGY-NfE/cDFaIwmPBAAJ
## Feb 2017:

View file

@ -5,11 +5,11 @@
# install `docker` (http://docker.com)
#
# Usage:
# cd to a folder where you want your game data to be (or where it already is).
# cd to a folder where you want your game data to be (or where it already is).
#
# docker run -it -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia
#
# (If your OS does not support $PWD, replace it with the full path to your current
#
# (If your OS does not support $PWD, replace it with the full path to your current
# folder).
#
# You will end up in a shell where the `evennia` command is available. From here you
@ -30,10 +30,10 @@ RUN apk update && apk add python py-pip python-dev py-setuptools gcc musl-dev jp
ADD . /usr/src/evennia
# install dependencies
RUN pip install -e /usr/src/evennia --trusted-host pypi.python.org
RUN pip install --upgrade pip && pip install /usr/src/evennia --trusted-host pypi.python.org
# add the game source when rebuilding a new docker image from inside
# a game dir
# a game dir
ONBUILD ADD . /usr/src/game
# make the game source hierarchy persistent with a named volume.
@ -48,7 +48,7 @@ WORKDIR /usr/src/game
ENV PS1 "evennia|docker \w $ "
# startup a shell when we start the container
ENTRYPOINT ["bash"]
ENTRYPOINT bash -c "source /usr/src/evennia/bin/unix/evennia-docker-start.sh"
# expose the telnet, webserver and websocket client ports
EXPOSE 4000 4001 4005

View file

@ -0,0 +1,13 @@
#! /bin/bash
# called by the Dockerfile to start the server in docker mode
# remove leftover .pid files (such as from when dropping the container)
rm /usr/src/game/server/*.pid >& /dev/null || true
# start evennia server; log to server.log but also output to stdout so it can
# be viewed with docker-compose logs
exec 3>&1; evennia start 2>&1 1>&3 | tee /usr/src/game/server/logs/server.log; exec 3>&-
# start a shell to keep the container running
bash

View file

@ -761,7 +761,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
elif _MULTISESSION_MODE in (2, 3):
# In this mode we by default end up at a character selection
# screen. We execute look on the account.
# we make sure to clean up the _playable_characers list in case
# we make sure to clean up the _playable_characters list in case
# any was deleted in the interim.
self.db._playable_characters = [char for char in self.db._playable_characters if char]
self.msg(self.at_look(target=self.db._playable_characters,

View file

@ -168,7 +168,7 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS):
if desc:
new_character.db.desc = desc
elif not new_character.db.desc:
new_character.db.desc = "This is an Account."
new_character.db.desc = "This is a character."
self.msg("Created new character %s. Use |w@ic %s|n to enter the game as this character."
% (new_character.key, new_character.key))

View file

@ -1455,6 +1455,13 @@ class CmdSetAttribute(ObjManipCommand):
Switch:
edit: Open the line editor (string values only)
script: If we're trying to set an attribute on a script
channel: If we're trying to set an attribute on a channel
account: If we're trying to set an attribute on an account
room: Setting an attribute on a room (global search)
exit: Setting an attribute on an exit (global search)
char: Setting an attribute on a character (global search)
character: Alias for char, as above.
Sets attributes on objects. The second form clears
a previously set attribute while the last form
@ -1555,6 +1562,38 @@ class CmdSetAttribute(ObjManipCommand):
# start the editor
EvEditor(self.caller, load, save, key="%s/%s" % (obj, attr))
def search_for_obj(self, objname):
"""
Searches for an object matching objname. The object may be of different typeclasses.
Args:
objname: Name of the object we're looking for
Returns:
A typeclassed object, or None if nothing is found.
"""
from evennia.utils.utils import variable_from_module
_AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit('.', 1))
caller = self.caller
if objname.startswith('*') or "account" in self.switches:
found_obj = caller.search_account(objname.lstrip('*'))
elif "script" in self.switches:
found_obj = _AT_SEARCH_RESULT(search.search_script(objname), caller)
elif "channel" in self.switches:
found_obj = _AT_SEARCH_RESULT(search.search_channel(objname), caller)
else:
global_search = True
if "char" in self.switches or "character" in self.switches:
typeclass = settings.BASE_CHARACTER_TYPECLASS
elif "room" in self.switches:
typeclass = settings.BASE_ROOM_TYPECLASS
elif "exit" in self.switches:
typeclass = settings.BASE_EXIT_TYPECLASS
else:
global_search = False
typeclass = None
found_obj = caller.search(objname, global_search=global_search, typeclass=typeclass)
return found_obj
def func(self):
"""Implement the set attribute - a limited form of @py."""
@ -1568,10 +1607,7 @@ class CmdSetAttribute(ObjManipCommand):
objname = self.lhs_objattr[0]['name']
attrs = self.lhs_objattr[0]['attrs']
if objname.startswith('*'):
obj = caller.search_account(objname.lstrip('*'))
else:
obj = caller.search(objname)
obj = self.search_for_obj(objname)
if not obj:
return

View file

@ -267,13 +267,17 @@ class CmdGet(COMMAND_DEFAULT_CLASS):
caller.msg("You can't get that.")
return
# calling at_before_get hook method
if not obj.at_before_get(caller):
return
obj.move_to(caller, quiet=True)
caller.msg("You pick up %s." % obj.name)
caller.location.msg_contents("%s picks up %s." %
(caller.name,
obj.name),
exclude=caller)
# calling hook method
# calling at_get hook method
obj.at_get(caller)
@ -308,6 +312,10 @@ class CmdDrop(COMMAND_DEFAULT_CLASS):
if not obj:
return
# Call the object script's at_before_drop() method.
if not obj.at_before_drop(caller):
return
obj.move_to(caller.location, quiet=True)
caller.msg("You drop %s." % (obj.name,))
caller.location.msg_contents("%s drops %s." %
@ -350,6 +358,11 @@ class CmdGive(COMMAND_DEFAULT_CLASS):
if not to_give.location == caller:
caller.msg("You are not holding %s." % to_give.key)
return
# calling at_before_give hook method
if not to_give.at_before_give(caller, target):
return
# give object
caller.msg("You give %s to %s." % (to_give.key, target.key))
to_give.move_to(target, quiet=True)

View file

@ -293,7 +293,7 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
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)
@ -557,7 +557,7 @@ def _create_character(session, new_account, typeclass, home, permissions):
# If no description is set, set a default description
if not new_character.db.desc:
new_character.db.desc = "This is an Account."
new_character.db.desc = "This is a character."
# We need to set this to have @ic auto-connect to this character
new_account.db._last_puppet = new_character
except Exception as e:

View file

@ -19,7 +19,7 @@ things you want from here into your game folder and change them there.
for any game. Allows safe trading of any godds (including coin)
* 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
@ -50,8 +50,9 @@ things you want from here into your game folder and change them there.
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.
* Wilderness (titeuf87 2017) - Make infinitely large wilderness areas
with dynamically created locations.
* UnixCommand (Vincent Le Geoff 2017) - Add commands with UNIX-style syntax.
@ -62,6 +63,9 @@ things you want from here into your game folder and change them there.
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

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

@ -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

@ -670,6 +670,15 @@ class TestGenderSub(CommandTest):
char = create_object(gendersub.GenderCharacter, key="Gendered", location=self.room1)
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
@ -926,7 +935,7 @@ class TestTutorialWorldRooms(CommandTest):
# test turnbattle
from evennia.contrib import turnbattle
from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range
from evennia.objects.objects import DefaultRoom
@ -934,60 +943,94 @@ class TestTurnBattleCmd(CommandTest):
# Test 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.")
# Test equipment commands
def test_turnbattleequipcmd(self):
# Start with equip module specific commands.
testweapon = create_object(tb_equip.TBEWeapon, key="test weapon")
testarmor = create_object(tb_equip.TBEArmor, key="test armor")
testweapon.move_to(self.char1)
testarmor.move_to(self.char1)
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.")
# 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 TestTurnBattleFunc(EvenniaTest):
# Test combat functions
def test_turnbattlefunc(self):
attacker = create_object(turnbattle.BattleCharacter, key="Attacker")
defender = create_object(turnbattle.BattleCharacter, key="Defender")
def test_tbbasicfunc(self):
attacker = create_object(tb_basic.TBBasicCharacter, key="Attacker")
defender = create_object(tb_basic.TBBasicCharacter, key="Defender")
testroom = create_object(DefaultRoom, key="Test Room")
attacker.location = testroom
defender.loaction = testroom
# Initiative roll
initiative = turnbattle.roll_init(attacker)
initiative = tb_basic.roll_init(attacker)
self.assertTrue(initiative >= 0 and initiative <= 1000)
# Attack roll
attack_roll = turnbattle.get_attack(attacker, defender)
attack_roll = tb_basic.get_attack(attacker, 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(attacker, defender)
self.assertTrue(defense_roll == 50)
# Damage roll
damage_roll = turnbattle.get_damage(attacker, defender)
damage_roll = tb_basic.get_damage(attacker, defender)
self.assertTrue(damage_roll >= 15 and damage_roll <= 25)
# Apply damage
defender.db.hp = 10
turnbattle.apply_damage(defender, 3)
tb_basic.apply_damage(defender, 3)
self.assertTrue(defender.db.hp == 7)
# Resolve attack
defender.db.hp = 40
turnbattle.resolve_attack(attacker, defender, attack_value=20, defense_value=10)
tb_basic.resolve_attack(attacker, defender, attack_value=20, defense_value=10)
self.assertTrue(defender.db.hp < 40)
# Combat cleanup
attacker.db.Combat_attribute = True
turnbattle.combat_cleanup(attacker)
tb_basic.combat_cleanup(attacker)
self.assertFalse(attacker.db.combat_attribute)
# Is in combat
self.assertFalse(turnbattle.is_in_combat(attacker))
self.assertFalse(tb_basic.is_in_combat(attacker))
# Set up turn handler script for further tests
attacker.location.scripts.add(turnbattle.TurnHandler)
attacker.location.scripts.add(tb_basic.TBBasicTurnHandler)
turnhandler = attacker.db.combat_TurnHandler
self.assertTrue(attacker.db.combat_TurnHandler)
# Set the turn handler's interval very high to keep it from repeating during tests.
turnhandler.interval = 10000
# Force turn order
turnhandler.db.fighters = [attacker, defender]
turnhandler.db.turn = 0
# Test is turn
self.assertTrue(turnbattle.is_turn(attacker))
self.assertTrue(tb_basic.is_turn(attacker))
# Spend actions
attacker.db.Combat_ActionsLeft = 1
turnbattle.spend_action(attacker, 1, action_name="Test")
tb_basic.spend_action(attacker, 1, action_name="Test")
self.assertTrue(attacker.db.Combat_ActionsLeft == 0)
self.assertTrue(attacker.db.Combat_LastAction == "Test")
# Initialize for combat
@ -1011,7 +1054,7 @@ class TestTurnBattleFunc(EvenniaTest):
turnhandler.turn_end_check(attacker)
self.assertTrue(turnhandler.db.turn == 1)
# Join fight
joiner = create_object(turnbattle.BattleCharacter, key="Joiner")
joiner = create_object(tb_basic.TBBasicCharacter, key="Joiner")
turnhandler.db.fighters = [attacker, defender]
turnhandler.db.turn = 0
turnhandler.join_fight(joiner)
@ -1019,7 +1062,208 @@ class TestTurnBattleFunc(EvenniaTest):
self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender])
# Remove the script at the end
turnhandler.stop()
# Test the combat functions in tb_equip too. They work mostly the same.
def test_tbequipfunc(self):
attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker")
defender = create_object(tb_equip.TBEquipCharacter, key="Defender")
testroom = create_object(DefaultRoom, key="Test Room")
attacker.location = testroom
defender.loaction = testroom
# Initiative roll
initiative = tb_equip.roll_init(attacker)
self.assertTrue(initiative >= 0 and initiative <= 1000)
# Attack roll
attack_roll = tb_equip.get_attack(attacker, defender)
self.assertTrue(attack_roll >= -50 and attack_roll <= 150)
# Defense roll
defense_roll = tb_equip.get_defense(attacker, defender)
self.assertTrue(defense_roll == 50)
# Damage roll
damage_roll = tb_equip.get_damage(attacker, defender)
self.assertTrue(damage_roll >= 0 and damage_roll <= 50)
# Apply damage
defender.db.hp = 10
tb_equip.apply_damage(defender, 3)
self.assertTrue(defender.db.hp == 7)
# Resolve attack
defender.db.hp = 40
tb_equip.resolve_attack(attacker, defender, attack_value=20, defense_value=10)
self.assertTrue(defender.db.hp < 40)
# Combat cleanup
attacker.db.Combat_attribute = True
tb_equip.combat_cleanup(attacker)
self.assertFalse(attacker.db.combat_attribute)
# Is in combat
self.assertFalse(tb_equip.is_in_combat(attacker))
# Set up turn handler script for further tests
attacker.location.scripts.add(tb_equip.TBEquipTurnHandler)
turnhandler = attacker.db.combat_TurnHandler
self.assertTrue(attacker.db.combat_TurnHandler)
# Set the turn handler's interval very high to keep it from repeating during tests.
turnhandler.interval = 10000
# Force turn order
turnhandler.db.fighters = [attacker, defender]
turnhandler.db.turn = 0
# Test is turn
self.assertTrue(tb_equip.is_turn(attacker))
# Spend actions
attacker.db.Combat_ActionsLeft = 1
tb_equip.spend_action(attacker, 1, action_name="Test")
self.assertTrue(attacker.db.Combat_ActionsLeft == 0)
self.assertTrue(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")
# Start turn
defender.db.Combat_ActionsLeft = 0
turnhandler.start_turn(defender)
self.assertTrue(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)
# 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)
# Join fight
joiner = create_object(tb_equip.TBEquipCharacter, 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()
# Test combat functions in tb_range too.
def test_tbrangefunc(self):
testroom = create_object(DefaultRoom, key="Test Room")
attacker = create_object(tb_range.TBRangeCharacter, key="Attacker", location=testroom)
defender = create_object(tb_range.TBRangeCharacter, key="Defender", location=testroom)
# Initiative roll
initiative = tb_range.roll_init(attacker)
self.assertTrue(initiative >= 0 and initiative <= 1000)
# Attack roll
attack_roll = tb_range.get_attack(attacker, defender, "test")
self.assertTrue(attack_roll >= 0 and attack_roll <= 100)
# Defense roll
defense_roll = tb_range.get_defense(attacker, defender, "test")
self.assertTrue(defense_roll == 50)
# Damage roll
damage_roll = tb_range.get_damage(attacker, defender)
self.assertTrue(damage_roll >= 15 and damage_roll <= 25)
# Apply damage
defender.db.hp = 10
tb_range.apply_damage(defender, 3)
self.assertTrue(defender.db.hp == 7)
# Resolve attack
defender.db.hp = 40
tb_range.resolve_attack(attacker, defender, "test", attack_value=20, defense_value=10)
self.assertTrue(defender.db.hp < 40)
# Combat cleanup
attacker.db.Combat_attribute = True
tb_range.combat_cleanup(attacker)
self.assertFalse(attacker.db.combat_attribute)
# Is in combat
self.assertFalse(tb_range.is_in_combat(attacker))
# Set up turn handler script for further tests
attacker.location.scripts.add(tb_range.TBRangeTurnHandler)
turnhandler = attacker.db.combat_TurnHandler
self.assertTrue(attacker.db.combat_TurnHandler)
# Set the turn handler's interval very high to keep it from repeating during tests.
turnhandler.interval = 10000
# Force turn order
turnhandler.db.fighters = [attacker, defender]
turnhandler.db.turn = 0
# Test is turn
self.assertTrue(tb_range.is_turn(attacker))
# Spend actions
attacker.db.Combat_ActionsLeft = 1
tb_range.spend_action(attacker, 1, action_name="Test")
self.assertTrue(attacker.db.Combat_ActionsLeft == 0)
self.assertTrue(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")
# Set up ranges again, since initialize_for_combat clears them
attacker.db.combat_range = {}
attacker.db.combat_range[attacker] = 0
attacker.db.combat_range[defender] = 1
defender.db.combat_range = {}
defender.db.combat_range[defender] = 0
defender.db.combat_range[attacker] = 1
# Start turn
defender.db.Combat_ActionsLeft = 0
turnhandler.start_turn(defender)
self.assertTrue(defender.db.Combat_ActionsLeft == 2)
# Next turn
turnhandler.db.fighters = [attacker, defender]
turnhandler.db.turn = 0
turnhandler.next_turn()
self.assertTrue(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)
# Join fight
joiner = create_object(tb_range.TBRangeCharacter, key="Joiner", location=testroom)
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])
# Now, test for approach/withdraw functions
self.assertTrue(tb_range.get_range(attacker, defender) == 1)
# Approach
tb_range.approach(attacker, defender)
self.assertTrue(tb_range.get_range(attacker, defender) == 0)
# Withdraw
tb_range.withdraw(attacker, defender)
self.assertTrue(tb_range.get_range(attacker, defender) == 1)
# Remove the script at the end
turnhandler.stop()
# 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 of the unixcommand module

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,42 @@
# 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_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

View file

@ -1497,6 +1497,25 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
"""
pass
def at_before_get(self, getter, **kwargs):
"""
Called by the default `get` command before this object has been
picked up.
Args:
getter (Object): The object about to get this object.
**kwargs (dict): Arbitrary, optional arguments for users
overriding the call (unused by default).
Returns:
shouldget (bool): If the object should be gotten or not.
Notes:
If this method returns False/None, the getting is cancelled
before it is even started.
"""
return True
def at_get(self, getter, **kwargs):
"""
Called by the default `get` command when this object has been
@ -1509,11 +1528,32 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
Notes:
This hook cannot stop the pickup from happening. Use
permissions for that.
permissions or the at_before_get() hook for that.
"""
pass
def at_before_give(self, giver, getter, **kwargs):
"""
Called by the default `give` command before this object has been
given.
Args:
giver (Object): The object about to give this object.
getter (Object): The object about to get this object.
**kwargs (dict): Arbitrary, optional arguments for users
overriding the call (unused by default).
Returns:
shouldgive (bool): If the object should be given or not.
Notes:
If this method returns False/None, the giving is cancelled
before it is even started.
"""
return True
def at_give(self, giver, getter, **kwargs):
"""
Called by the default `give` command when this object has been
@ -1527,11 +1567,31 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
Notes:
This hook cannot stop the give from happening. Use
permissions for that.
permissions or the at_before_give() hook for that.
"""
pass
def at_before_drop(self, dropper, **kwargs):
"""
Called by the default `drop` command before this object has been
dropped.
Args:
dropper (Object): The object which will drop this object.
**kwargs (dict): Arbitrary, optional arguments for users
overriding the call (unused by default).
Returns:
shoulddrop (bool): If the object should be dropped or not.
Notes:
If this method returns False/None, the dropping is cancelled
before it is even started.
"""
return True
def at_drop(self, dropper, **kwargs):
"""
Called by the default `drop` command when this object has been
@ -1544,7 +1604,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
Notes:
This hook cannot stop the drop from happening. Use
permissions from that.
permissions or the at_before_drop() hook for that.
"""
pass
@ -1624,11 +1684,11 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
# whisper mode
msg_type = 'whisper'
msg_self = '{self} whisper to {all_receivers}, "{speech}"' if msg_self is True else msg_self
msg_receivers = '{object} whispers: "{speech}"'
msg_receivers = msg_receivers or '{object} whispers: "{speech}"'
msg_location = None
else:
msg_self = '{self} say, "{speech}"' if msg_self is True else msg_self
msg_receivers = None
msg_location = msg_location or '{object} says, "{speech}"'
custom_mapping = kwargs.get('mapping', {})
@ -1673,9 +1733,14 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
"receiver": None,
"speech": message}
location_mapping.update(custom_mapping)
exclude = []
if msg_self:
exclude.append(self)
if receivers:
exclude.extend(receivers)
self.location.msg_contents(text=(msg_location, {"type": msg_type}),
from_obj=self,
exclude=(self, ) if msg_self else None,
exclude=exclude,
mapping=location_mapping)

View file

@ -214,7 +214,7 @@ class ScriptDBManager(TypedObjectManager):
VALIDATE_ITERATION -= 1
return nr_started, nr_stopped
def search_script(self, ostring, obj=None, only_timed=False):
def search_script(self, ostring, obj=None, only_timed=False, typeclass=None):
"""
Search for a particular script.
@ -224,6 +224,7 @@ class ScriptDBManager(TypedObjectManager):
this object
only_timed (bool): Limit search only to scripts that run
on a timer.
typeclass (class or str): Typeclass or path to typeclass.
"""
@ -237,10 +238,17 @@ class ScriptDBManager(TypedObjectManager):
(only_timed and dbref_match.interval)):
return [dbref_match]
if typeclass:
if callable(typeclass):
typeclass = u"%s.%s" % (typeclass.__module__, typeclass.__name__)
else:
typeclass = u"%s" % typeclass
# not a dbref; normal search
obj_restriction = obj and Q(db_obj=obj) or Q()
timed_restriction = only_timed and Q(interval__gt=0) or Q()
scripts = self.filter(timed_restriction & obj_restriction & Q(db_key__iexact=ostring))
timed_restriction = only_timed and Q(db_interval__gt=0) or Q()
typeclass_restriction = typeclass and Q(db_typeclass_path=typeclass) or Q()
scripts = self.filter(timed_restriction & obj_restriction & typeclass_restriction & Q(db_key__iexact=ostring))
return scripts
# back-compatibility alias
script_search = search_script

View file

@ -192,7 +192,7 @@ if AMP_ENABLED:
from evennia.server import amp
print(' amp (to Server): %s' % AMP_PORT)
print(' amp (to Server): %s (internal)' % AMP_PORT)
factory = amp.AmpClientFactory(PORTAL)
amp_client = internet.TCPClient(AMP_HOST, AMP_PORT, factory)
@ -223,7 +223,7 @@ if TELNET_ENABLED:
telnet_service.setName('EvenniaTelnet%s' % pstring)
PORTAL.services.addService(telnet_service)
print(' telnet%s: %s' % (ifacestr, port))
print(' telnet%s: %s (external)' % (ifacestr, port))
if SSL_ENABLED:
@ -249,7 +249,7 @@ if SSL_ENABLED:
ssl_service.setName('EvenniaSSL%s' % pstring)
PORTAL.services.addService(ssl_service)
print(" ssl%s: %s" % (ifacestr, port))
print(" ssl%s: %s (external)" % (ifacestr, port))
if SSH_ENABLED:
@ -273,7 +273,7 @@ if SSH_ENABLED:
ssh_service.setName('EvenniaSSH%s' % pstring)
PORTAL.services.addService(ssh_service)
print(" ssh%s: %s" % (ifacestr, port))
print(" ssh%s: %s (external)" % (ifacestr, port))
if WEBSERVER_ENABLED:
@ -287,7 +287,6 @@ if WEBSERVER_ENABLED:
if interface not in ('0.0.0.0', '::') or len(WEBSERVER_INTERFACES) > 1:
ifacestr = "-%s" % interface
for proxyport, serverport in WEBSERVER_PORTS:
pstring = "%s:%s<->%s" % (ifacestr, proxyport, serverport)
web_root = EvenniaReverseProxyResource('127.0.0.1', serverport, '')
webclientstr = ""
if WEBCLIENT_ENABLED:
@ -305,21 +304,20 @@ if WEBSERVER_ENABLED:
from evennia.server.portal import webclient
from evennia.utils.txws import WebSocketFactory
interface = WEBSOCKET_CLIENT_INTERFACE
w_interface = WEBSOCKET_CLIENT_INTERFACE
w_ifacestr = ''
if w_interface not in ('0.0.0.0', '::') or len(WEBSERVER_INTERFACES) > 1:
w_ifacestr = "-%s" % interface
port = WEBSOCKET_CLIENT_PORT
ifacestr = ""
if interface not in ('0.0.0.0', '::'):
ifacestr = "-%s" % interface
pstring = "%s:%s" % (ifacestr, port)
factory = protocol.ServerFactory()
factory.noisy = False
factory.protocol = webclient.WebSocketClient
factory.sessionhandler = PORTAL_SESSIONS
websocket_service = internet.TCPServer(port, WebSocketFactory(factory), interface=interface)
websocket_service.setName('EvenniaWebSocket%s' % pstring)
websocket_service = internet.TCPServer(port, WebSocketFactory(factory), interface=w_interface)
websocket_service.setName('EvenniaWebSocket%s:%s' % (w_ifacestr, proxyport))
PORTAL.services.addService(websocket_service)
websocket_started = True
webclientstr = "\n + webclient%s" % pstring
webclientstr = "\n + webclient-websocket%s: %s (external)" % (w_ifacestr, proxyport)
web_root = Website(web_root, logPath=settings.HTTP_LOG_FILE)
proxy_service = internet.TCPServer(proxyport,
@ -327,7 +325,7 @@ if WEBSERVER_ENABLED:
interface=interface)
proxy_service.setName('EvenniaWebProxy%s' % pstring)
PORTAL.services.addService(proxy_service)
print(" webproxy%s:%s (<-> %s)%s" % (ifacestr, proxyport, serverport, webclientstr))
print(" website-proxy%s: %s (external) %s" % (ifacestr, proxyport, webclientstr))
for plugin_module in PORTAL_SERVICES_PLUGIN_MODULES:

View file

@ -20,9 +20,9 @@ from twisted.conch.interfaces import IConchUser
_SSH_IMPORT_ERROR = """
ERROR: Missing crypto library for SSH. Install it with
pip install cryptography
pip install cryptography pyasn1
(On older Twisted versions you may have to do 'pip install pycrypto pyasn1 instead).
(On older Twisted versions you may have to do 'pip install pycrypto pyasn1' instead).
If you get a compilation error you must install a C compiler and the
SSL dev headers (On Debian-derived systems this is the gcc and libssl-dev

View file

@ -50,9 +50,12 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
# when it reaches 0 the portal/server syncs their data
self.handshakes = 8 # suppress-go-ahead, naws, ttype, mccp, mssp, msdp, gmcp, mxp
self.init_session(self.protocol_name, client_address, self.factory.sessionhandler)
self.protocol_flags["ENCODING"] = settings.ENCODINGS[0] if settings.ENCODINGS else 'utf-8'
# add this new connection to sessionhandler so
# the Server becomes aware of it.
self.sessionhandler.connect(self)
# change encoding to ENCODINGS[0] which reflects Telnet default encoding
# suppress go-ahead
self.sga = suppress_ga.SuppressGA(self)

View file

@ -546,7 +546,7 @@ if AMP_ENABLED:
ifacestr = ""
if AMP_INTERFACE != '127.0.0.1':
ifacestr = "-%s" % AMP_INTERFACE
print(' amp (to Portal)%s: %s' % (ifacestr, AMP_PORT))
print(' amp (to Portal)%s: %s (internal)' % (ifacestr, AMP_PORT))
from evennia.server import amp
@ -586,7 +586,7 @@ if WEBSERVER_ENABLED:
webserver.setName('EvenniaWebServer%s' % serverport)
EVENNIA.services.addService(webserver)
print(" webserver: %s" % serverport)
print(" webserver: %s (internal)" % serverport)
ENABLED = []
if IRC_ENABLED:

View file

@ -7,7 +7,6 @@ from builtins import object
import time
#------------------------------------------------------------
# Server Session
#------------------------------------------------------------

View file

@ -83,15 +83,20 @@ WEBCLIENT_ENABLED = True
# default webclient will use this and only use the ajax version if the browser
# is too old to support websockets. Requires WEBCLIENT_ENABLED.
WEBSOCKET_CLIENT_ENABLED = True
# Server-side websocket port to open for the webclient.
# Server-side websocket port to open for the webclient. Note that this value will
# be dynamically encoded in the webclient html page to allow the webclient to call
# home. If the external encoded value needs to be different than this, due to
# working through a proxy or docker port-remapping, the environment variable
# WEBCLIENT_CLIENT_PROXY_PORT can be used to override this port only for the
# front-facing client's sake.
WEBSOCKET_CLIENT_PORT = 4005
# Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6.
WEBSOCKET_CLIENT_INTERFACE = '0.0.0.0'
# Actual URL for webclient component to reach the websocket. You only need
# to set this if you know you need it, like using some sort of proxy setup.
# If given it must be on the form "ws://hostname" (WEBSOCKET_CLIENT_PORT will
# be automatically appended). If left at None, the client will itself
# figure out this url based on the server's hostname.
# If given it must be on the form "ws[s]://hostname[:port]". If left at None,
# the client will itself figure out this url based on the server's hostname.
# e.g. ws://external.example.com or wss://external.example.com:443
WEBSOCKET_CLIENT_URL = None
# This determine's whether Evennia's custom admin page is used, or if the
# standard Django admin is used.
@ -166,6 +171,7 @@ IDLE_COMMAND = "idle"
# given, this list is tried, in order, aborting on the first match.
# Add sets for languages/regions your accounts are likely to use.
# (see http://en.wikipedia.org/wiki/Character_encoding)
# Telnet default encoding, unless specified by the client, will be ENCODINGS[0].
ENCODINGS = ["utf-8", "latin-1", "ISO-8859-1"]
# Regular expression applied to all output to a given session in order
# to strip away characters (usually various forms of decorations) for the benefit

View file

@ -63,23 +63,31 @@ menu is immediately exited and the default "look" command is called.
text (str, tuple or None): Text shown at this node. If a tuple, the
second element in the tuple is a help text to display at this
node when the user enters the menu help command there.
options (tuple, dict or None): (
{'key': name, # can also be a list of aliases. A special key is
# "_default", which marks this option as the default
# fallback when no other option matches the user input.
'desc': description, # optional description
'goto': nodekey, # node to go to when chosen. This can also be a callable with
# caller and/or raw_string args. It must return a string
# with the key pointing to the node to go to.
'exec': nodekey}, # node or callback to trigger as callback when chosen. This
# will execute *before* going to the next node. Both node
# and the explicit callback will be called as normal nodes
# (with caller and/or raw_string args). If the callable/node
# returns a single string (only), this will replace the current
# goto location string in-place (if a goto callback, it will never fire).
# Note that relying to much on letting exec assign the goto
# location can make it hard to debug your menu logic.
{...}, ...)
options (tuple, dict or None): If `None`, this exits the menu.
If a single dict, this is a single-option node. If a tuple,
it should be a tuple of option dictionaries. Option dicts have
the following keys:
- `key` (str or tuple, optional): What to enter to choose this option.
If a tuple, it must be a tuple of strings, where the first string is the
key which will be shown to the user and the others are aliases.
If unset, the options' number will be used. The special key `_default`
marks this option as the default fallback when no other option matches
the user input. There can only be one `_default` option per node. It
will not be displayed in the list.
- `desc` (str, optional): This describes what choosing the option will do.
- `goto` (str, tuple or callable): If string, should be the name of node to go to
when this option is selected. If a callable, it has the signature
`callable(caller[,raw_input][,**kwargs]). If a tuple, the first element
is the callable and the second is a dict with the **kwargs to pass to
the callable. Those kwargs will also be passed into the next node if possible.
Such a callable should return either a str or a (str, dict), where the
string is the name of the next node to go to and the dict is the new,
(possibly modified) kwarg to pass into the next node.
- `exec` (str, callable or tuple, optional): This takes the same input as `goto` above
and runs before it. If given a node name, the node will be executed but will not
be considered the next node. If node/callback returns str or (str, dict), these will
replace the `goto` step (`goto` callbacks will not fire), with the string being the
next node name and the optional dict acting as the kwargs-input for the next node.
If key is not given, the option will automatically be identified by
its number 1..N.
@ -95,7 +103,7 @@ Example:
"This is help text for this node")
options = ({"key": "testing",
"desc": "Select this to go to node 2",
"goto": "node2",
"goto": ("node2", {"foo": "bar"}),
"exec": "callback1"},
{"desc": "Go to node 3.",
"goto": "node3"})
@ -108,12 +116,13 @@ Example:
# by the normal 'goto' option key above.
caller.msg("Callback called!")
def node2(caller):
def node2(caller, **kwargs):
text = '''
This is node 2. It only allows you to go back
to the original node1. This extra indent will
be stripped. We don't include a help text.
'''
be stripped. We don't include a help text but
here are the variables passed to us: {}
'''.format(kwargs)
options = {"goto": "node1"}
return text, options
@ -148,6 +157,7 @@ evennia.utils.evmenu`.
"""
from __future__ import print_function
import random
from builtins import object, range
from textwrap import dedent
@ -260,7 +270,7 @@ class CmdEvMenuNode(Command):
err = "Menu object not found as %s.ndb._menutree!" % orig_caller
orig_caller.msg(err) # don't give the session as a kwarg here, direct to original
raise EvMenuError(err)
# we must do this after the caller with the menui has been correctly identified since it
# we must do this after the caller with the menu has been correctly identified since it
# can be either Account, Object or Session (in the latter case this info will be superfluous).
caller.ndb._menutree._session = self.session
# we have a menu, use it.
@ -359,9 +369,10 @@ class EvMenu(object):
re-run with the same input arguments - so be careful if you are counting
up some persistent counter or similar - the counter may be run twice if
reload happens on the node that does that.
startnode_input (str, optional): Send an input text to `startnode` as if
a user input text from a fictional previous node. When the server reloads,
the latest visited node will be re-run using this kwarg.
startnode_input (str or (str, dict), optional): Send an input text to `startnode` as if
a user input text from a fictional previous node. If including the dict, this will
be passed as **kwargs to that node. When the server reloads,
the latest visited node will be re-run as `node(caller, raw_string, **kwargs)`.
session (Session, optional): This is useful when calling EvMenu from an account
in multisession mode > 2. Note that this session only really relevant
for the very first display of the first node - after that, EvMenu itself
@ -391,6 +402,7 @@ class EvMenu(object):
self._startnode = startnode
self._menutree = self._parse_menudata(menudata)
self._persistent = persistent
self._quitting = False
if startnode not in self._menutree:
raise EvMenuError("Start node '%s' not in menu tree!" % startnode)
@ -417,6 +429,12 @@ class EvMenu(object):
self.nodetext = None
self.helptext = None
self.options = None
self.nodename = None
self.node_kwargs = {}
# used for testing
self.test_options = {}
self.test_nodetext = ""
# assign kwargs as initialization vars on ourselves.
if set(("_startnode", "_menutree", "_session", "_persistent",
@ -463,8 +481,13 @@ class EvMenu(object):
menu_cmdset.priority = int(cmdset_priority)
self.caller.cmdset.add(menu_cmdset, permanent=persistent)
startnode_kwargs = {}
if isinstance(startnode_input, (tuple, list)) and len(startnode_input) > 1:
startnode_input, startnode_kwargs = startnode_input[:2]
if not isinstance(startnode_kwargs, dict):
raise EvMenuError("startnode_input must be either a str or a tuple (str, dict).")
# start the menu
self.goto(self._startnode, startnode_input)
self.goto(self._startnode, startnode_input, **startnode_kwargs)
def _parse_menudata(self, menudata):
"""
@ -519,7 +542,42 @@ class EvMenu(object):
# format the entire node
return self.node_formatter(nodetext, optionstext)
def _execute_node(self, nodename, raw_string):
def _safe_call(self, callback, raw_string, **kwargs):
"""
Call a node-like callable, with a variable number of raw_string, *args, **kwargs, all of
which should work also if not present (only `caller` is always required). Return its result.
"""
try:
try:
nargs = len(getargspec(callback).args)
except TypeError:
raise EvMenuError("Callable {} doesn't accept any arguments!".format(callback))
supports_kwargs = bool(getargspec(callback).keywords)
if nargs <= 0:
raise EvMenuError("Callable {} doesn't accept any arguments!".format(callback))
if supports_kwargs:
if nargs > 1:
ret = callback(self.caller, raw_string, **kwargs)
# callback accepting raw_string, **kwargs
else:
# callback accepting **kwargs
ret = callback(self.caller, **kwargs)
elif nargs > 1:
# callback accepting raw_string
ret = callback(self.caller, raw_string)
else:
# normal callback, only the caller as arg
ret = callback(self.caller)
except EvMenuError:
errmsg = _ERR_GENERAL.format(nodename=callback)
self.caller.msg(errmsg, self._session)
raise
return ret
def _execute_node(self, nodename, raw_string, **kwargs):
"""
Execute a node.
@ -528,6 +586,7 @@ class EvMenu(object):
raw_string (str): The raw default string entered on the
previous node (only used if the node accepts it as an
argument)
kwargs (any, optional): Optional kwargs for the node.
Returns:
nodetext, options (tuple): The node text (a string or a
@ -540,47 +599,25 @@ class EvMenu(object):
self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session)
raise EvMenuError
try:
# the node should return data as (text, options)
if len(getargspec(node).args) > 1:
# a node accepting raw_string
nodetext, options = node(self.caller, raw_string)
ret = self._safe_call(node, raw_string, **kwargs)
if isinstance(ret, (tuple, list)) and len(ret) > 1:
nodetext, options = ret[:2]
else:
# a normal node, only accepting caller
nodetext, options = node(self.caller)
nodetext, options = ret, None
except KeyError:
self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session)
raise EvMenuError
except Exception:
self.caller.msg(_ERR_GENERAL.format(nodename=nodename), session=self._session)
raise
# store options to make them easier to test
self.test_options = options
self.test_nodetext = nodetext
return nodetext, options
def display_nodetext(self):
self.caller.msg(self.nodetext, session=self._session)
def display_helptext(self):
self.caller.msg(self.helptext, session=self._session)
def callback_goto(self, callback, goto, raw_string):
"""
Call callback and goto in sequence.
Args:
callback (callable or str): Callback to run before goto. If
the callback returns a string, this is used to replace
the `goto` string before going to the next node.
goto (str): The target node to go to next (unless replaced
by `callable`)..
raw_string (str): The original user input.
"""
if callback:
# replace goto only if callback returns
goto = self.callback(callback, raw_string) or goto
if goto:
self.goto(goto, raw_string)
def callback(self, nodename, raw_string):
def run_exec(self, nodename, raw_string, **kwargs):
"""
Run a function or node as a callback (with the 'exec' option key).
@ -592,6 +629,8 @@ class EvMenu(object):
raw_string (str): The raw default string entered on the
previous node (only used if the node accepts it as an
argument)
kwargs (any): These are optional kwargs passed into goto
Returns:
new_goto (str or None): A replacement goto location string or
None (no replacement).
@ -602,36 +641,36 @@ class EvMenu(object):
relying on this.
"""
if callable(nodename):
# this is a direct callable - execute it directly
try:
if len(getargspec(nodename).args) > 1:
# callable accepting raw_string
ret = nodename(self.caller, raw_string)
else:
# normal callable, only the caller as arg
ret = nodename(self.caller)
except Exception:
self.caller.msg(_ERR_GENERAL.format(nodename=nodename), self._session)
raise
else:
# nodename is a string; lookup as node
try:
try:
if callable(nodename):
# this is a direct callable - execute it directly
ret = self._safe_call(nodename, raw_string, **kwargs)
if isinstance(ret, (tuple, list)):
if not len(ret) > 1 or not isinstance(ret[1], dict):
raise EvMenuError("exec callable must return either None, str or (str, dict)")
ret, kwargs = ret[:2]
else:
# nodename is a string; lookup as node and run as node in-place (don't goto it)
# execute the node
ret = self._execute_node(nodename, raw_string)
except EvMenuError as err:
errmsg = "Error in exec '%s' (input: '%s'): %s" % (nodename, raw_string, err)
self.caller.msg("|r%s|n" % errmsg)
logger.log_trace(errmsg)
return
ret = self._execute_node(nodename, raw_string, **kwargs)
if isinstance(ret, (tuple, list)):
if not len(ret) > 1 and ret[1] and not isinstance(ret[1], dict):
raise EvMenuError("exec node must return either None, str or (str, dict)")
ret, kwargs = ret[:2]
except EvMenuError as err:
errmsg = "Error in exec '%s' (input: '%s'): %s" % (nodename, raw_string.rstrip(), err)
self.caller.msg("|r%s|n" % errmsg)
logger.log_trace(errmsg)
return
if isinstance(ret, basestring):
# only return a value if a string (a goto target), ignore all other returns
return ret
return ret, kwargs
return None
def goto(self, nodename, raw_string):
def goto(self, nodename, raw_string, **kwargs):
"""
Run a node by name
Run a node by name, optionally dynamically generating that name first.
Args:
nodename (str or callable): Name of node or a callable
@ -642,24 +681,48 @@ class EvMenu(object):
argument)
"""
if callable(nodename):
try:
if len(getargspec(nodename).args) > 1:
# callable accepting raw_string
nodename = nodename(self.caller, raw_string)
def _extract_goto_exec(option_dict):
"Helper: Get callables and their eventual kwargs"
goto_kwargs, exec_kwargs = {}, {}
goto, execute = option_dict.get("goto", None), option_dict.get("exec", None)
if goto and isinstance(goto, (tuple, list)):
if len(goto) > 1:
goto, goto_kwargs = goto[:2] # ignore any extra arguments
if not hasattr(goto_kwargs, "__getitem__"):
# not a dict-like structure
raise EvMenuError("EvMenu node {}: goto kwargs is not a dict: {}".format(
nodename, goto_kwargs))
else:
nodename = nodename(self.caller)
except Exception:
self.caller.msg(_ERR_GENERAL.format(nodename=nodename), self._session)
raise
goto = goto[0]
if execute and isinstance(execute, (tuple, list)):
if len(execute) > 1:
execute, exec_kwargs = execute[:2] # ignore any extra arguments
if not hasattr(exec_kwargs, "__getitem__"):
# not a dict-like structure
raise EvMenuError("EvMenu node {}: exec kwargs is not a dict: {}".format(
nodename, goto_kwargs))
else:
execute = execute[0]
return goto, goto_kwargs, execute, exec_kwargs
if callable(nodename):
# run the "goto" callable, if possible
inp_nodename = nodename
nodename = self._safe_call(nodename, raw_string, **kwargs)
if isinstance(nodename, (tuple, list)):
if not len(nodename) > 1 or not isinstance(nodename[1], dict):
raise EvMenuError(
"{}: goto callable must return str or (str, dict)".format(inp_nodename))
nodename, kwargs = nodename[:2]
try:
# execute the node, make use of the returns.
nodetext, options = self._execute_node(nodename, raw_string)
# execute the found node, make use of the returns.
nodetext, options = self._execute_node(nodename, raw_string, **kwargs)
except EvMenuError:
return
if self._persistent:
self.caller.attributes.add("_menutree_saved_startnode", (nodename, raw_string))
self.caller.attributes.add("_menutree_saved_startnode",
(nodename, (raw_string, kwargs)))
# validation of the node return values
helptext = ""
@ -680,22 +743,25 @@ class EvMenu(object):
for inum, dic in enumerate(options):
# fix up the option dicts
keys = make_iter(dic.get("key"))
desc = dic.get("desc", dic.get("text", None))
if "_default" in keys:
keys = [key for key in keys if key != "_default"]
desc = dic.get("desc", dic.get("text", _ERR_NO_OPTION_DESC).strip())
goto, execute = dic.get("goto", None), dic.get("exec", None)
self.default = (goto, execute)
goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic)
self.default = (goto, goto_kwargs, execute, exec_kwargs)
else:
# use the key (only) if set, otherwise use the running number
keys = list(make_iter(dic.get("key", str(inum + 1).strip())))
desc = dic.get("desc", dic.get("text", _ERR_NO_OPTION_DESC).strip())
goto, execute = dic.get("goto", None), dic.get("exec", None)
goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic)
if keys:
display_options.append((keys[0], desc))
for key in keys:
if goto or execute:
self.options[strip_ansi(key).strip().lower()] = (goto, execute)
self.options[strip_ansi(key).strip().lower()] = \
(goto, goto_kwargs, execute, exec_kwargs)
self.nodetext = self._format_node(nodetext, display_options)
self.node_kwargs = kwargs
self.nodename = nodename
# handle the helptext
if helptext:
@ -709,17 +775,44 @@ class EvMenu(object):
if not options:
self.close_menu()
def run_exec_then_goto(self, runexec, goto, raw_string, runexec_kwargs=None, goto_kwargs=None):
"""
Call 'exec' callback and goto (which may also be a callable) in sequence.
Args:
runexec (callable or str): Callback to run before goto. If
the callback returns a string, this is used to replace
the `goto` string/callable before being passed into the goto handler.
goto (str): The target node to go to next (may be replaced
by `runexec`)..
raw_string (str): The original user input.
runexec_kwargs (dict, optional): Optional kwargs for runexec.
goto_kwargs (dict, optional): Optional kwargs for goto.
"""
if runexec:
# replace goto only if callback returns
goto, goto_kwargs = (
self.run_exec(runexec, raw_string,
**(runexec_kwargs if runexec_kwargs else {})) or
(goto, goto_kwargs))
if goto:
self.goto(goto, raw_string, **(goto_kwargs if goto_kwargs else {}))
def close_menu(self):
"""
Shutdown menu; occurs when reaching the end node or using the quit command.
"""
self.caller.cmdset.remove(EvMenuCmdSet)
del self.caller.ndb._menutree
if self._persistent:
self.caller.attributes.remove("_menutree_saved")
self.caller.attributes.remove("_menutree_saved_startnode")
if self.cmd_on_exit is not None:
self.cmd_on_exit(self.caller, self)
if not self._quitting:
# avoid multiple calls from different sources
self._quitting = True
self.caller.cmdset.remove(EvMenuCmdSet)
del self.caller.ndb._menutree
if self._persistent:
self.caller.attributes.remove("_menutree_saved")
self.caller.attributes.remove("_menutree_saved_startnode")
if self.cmd_on_exit is not None:
self.cmd_on_exit(self.caller, self)
def parse_input(self, raw_string):
"""
@ -734,13 +827,13 @@ class EvMenu(object):
should also report errors directly to the user.
"""
cmd = raw_string.strip().lower()
cmd = strip_ansi(raw_string.strip().lower())
if cmd in self.options:
# this will take precedence over the default commands
# below
goto, callback = self.options[cmd]
self.callback_goto(callback, goto, raw_string)
goto, goto_kwargs, execfunc, exec_kwargs = self.options[cmd]
self.run_exec_then_goto(execfunc, goto, raw_string, exec_kwargs, goto_kwargs)
elif self.auto_look and cmd in ("look", "l"):
self.display_nodetext()
elif self.auto_help and cmd in ("help", "h"):
@ -748,8 +841,8 @@ class EvMenu(object):
elif self.auto_quit and cmd in ("quit", "q", "exit"):
self.close_menu()
elif self.default:
goto, callback = self.default
self.callback_goto(callback, goto, raw_string)
goto, goto_kwargs, execfunc, exec_kwargs = self.default
self.run_exec_then_goto(execfunc, goto, raw_string, exec_kwargs, goto_kwargs)
else:
self.caller.msg(_HELP_NO_OPTION_MATCH, session=self._session)
@ -757,6 +850,12 @@ class EvMenu(object):
# no options - we are at the end of the menu.
self.close_menu()
def display_nodetext(self):
self.caller.msg(self.nodetext, session=self._session)
def display_helptext(self):
self.caller.msg(self.helptext, session=self._session)
# formatters - override in a child class
def nodetext_formatter(self, nodetext):
@ -799,16 +898,17 @@ class EvMenu(object):
for key, desc in optionlist:
if not (key or desc):
continue
desc_string = ": %s" % desc if desc else ""
table_width_max = max(table_width_max,
max(m_len(p) for p in key.split("\n")) +
max(m_len(p) for p in desc.split("\n")) + colsep)
max(m_len(p) for p in desc_string.split("\n")) + colsep)
raw_key = strip_ansi(key)
if raw_key != key:
# already decorations in key definition
table.append(" |lc%s|lt%s|le: %s" % (raw_key, key, desc))
table.append(" |lc%s|lt%s|le%s" % (raw_key, key, desc_string))
else:
# add a default white color to key
table.append(" |lc%s|lt|w%s|n|le: %s" % (raw_key, raw_key, desc))
table.append(" |lc%s|lt|w%s|n|le%s" % (raw_key, raw_key, desc_string))
ncols = (_MAX_TEXT_WIDTH // table_width_max) + 1 # number of ncols
@ -996,6 +1096,10 @@ def get_input(caller, prompt, callback, session=None, *args, **kwargs):
#
# -------------------------------------------------------------
def _generate_goto(caller, **kwargs):
return kwargs.get("name", "test_dynamic_node"), {"name": "replaced!"}
def test_start_node(caller):
menu = caller.ndb._menutree
text = """
@ -1020,6 +1124,9 @@ def test_start_node(caller):
{"key": ("|yV|niew", "v"),
"desc": "View your own name",
"goto": "test_view_node"},
{"key": ("|yD|nynamic", "d"),
"desc": "Dynamic node",
"goto": (_generate_goto, {"name": "test_dynamic_node"})},
{"key": ("|yQ|nuit", "quit", "q", "Q"),
"desc": "Quit this menu example.",
"goto": "test_end_node"},
@ -1029,7 +1136,7 @@ def test_start_node(caller):
def test_look_node(caller):
text = ""
text = "This is a custom look location!"
options = {"key": ("|yL|nook", "l"),
"desc": "Go back to the previous menu.",
"goto": "test_start_node"}
@ -1056,12 +1163,11 @@ def test_set_node(caller):
""")
options = {"key": ("back (default)", "_default"),
"desc": "back to main",
"goto": "test_start_node"}
return text, options
def test_view_node(caller):
def test_view_node(caller, **kwargs):
text = """
Your name is |g%s|n!
@ -1071,9 +1177,14 @@ def test_view_node(caller):
-always- use numbers (1...N) to refer to listed options also if you
don't see a string option key (try it!).
""" % caller.key
options = {"desc": "back to main",
"goto": "test_start_node"}
return text, options
if kwargs.get("executed_from_dynamic_node", False):
# we are calling this node as a exec, skip return values
caller.msg("|gCalled from dynamic node:|n \n {}".format(text))
return
else:
options = {"desc": "back to main",
"goto": "test_start_node"}
return text, options
def test_displayinput_node(caller, raw_string):
@ -1089,12 +1200,48 @@ def test_displayinput_node(caller, raw_string):
makes it hidden from view. It catches all input (except the
in-menu help/quit commands) and will, in this case, bring you back
to the start node.
""" % raw_string
""" % raw_string.rstrip()
options = {"key": "_default",
"goto": "test_start_node"}
return text, options
def _test_call(caller, raw_input, **kwargs):
mode = kwargs.get("mode", "exec")
caller.msg("\n|y'{}' |n_test_call|y function called with\n "
"caller: |n{}\n |yraw_input: \"|n{}|y\" \n kwargs: |n{}\n".format(
mode, caller, raw_input.rstrip(), kwargs))
if mode == "exec":
kwargs = {"random": random.random()}
caller.msg("function modify kwargs to {}".format(kwargs))
else:
caller.msg("|ypassing function kwargs without modification.|n")
return "test_dynamic_node", kwargs
def test_dynamic_node(caller, **kwargs):
text = """
This is a dynamic node with input:
{}
""".format(kwargs)
options = ({"desc": "pass a new random number to this node",
"goto": ("test_dynamic_node", {"random": random.random()})},
{"desc": "execute a func with kwargs",
"exec": (_test_call, {"mode": "exec", "test_random": random.random()})},
{"desc": "dynamic_goto",
"goto": (_test_call, {"mode": "goto", "goto_input": "test"})},
{"desc": "exec test_view_node with kwargs",
"exec": ("test_view_node", {"executed_from_dynamic_node": True}),
"goto": "test_dynamic_node"},
{"desc": "back to main",
"goto": "test_start_node"})
return text, options
def test_end_node(caller):
text = """
This is the end of the menu and since it has no options the menu

View file

@ -174,7 +174,7 @@ def _batch_create_object(*objparams):
objects (list): A list of created objects
Notes:
The `exec` list will execute arbitrary python code so don't allow this to be availble to
The `exec` list will execute arbitrary python code so don't allow this to be available to
unprivileged users!
"""

View file

@ -1,24 +1,227 @@
"""
Unit tests for the EvMenu system
TODO: This need expansion.
This sets up a testing parent for testing EvMenu trees. It is configured by subclassing the
`TestEvMenu` class from this module and setting the class variables to point to the menu that should
be tested and how it should be called.
Without adding any further test methods, the tester will process all nodes of the menu, depth first,
by stepping through all options for every node. Optionally, it can check that all nodes are visited.
It will create a hierarchical list of node names that describes the tree structure. This can then be
compared against a template to make sure the menu structure is sound. Easiest way to use this is to
run the test once to see how the structure looks.
The system also allows for testing the returns of each node as part of the parsing.
To help debug the menu, turn on `debug_output`, which will print the traversal process in detail.
"""
import copy
from django.test import TestCase
from evennia.utils import evmenu
from mock import Mock
from evennia.utils import ansi
from mock import MagicMock
class TestEvMenu(TestCase):
"Run the EvMenu testing."
menutree = {} # can also be the path to the menu tree
startnode = "start"
cmdset_mergetype = "Replace"
cmdset_priority = 1
auto_quit = True
auto_look = True
auto_help = True
cmd_on_exit = "look"
persistent = False
startnode_input = ""
kwargs = {}
# if all nodes must be visited for the test to pass. This is not on
# by default since there may be exec-nodes that are made to not be
# visited.
expect_all_nodes = False
# this is compared against the full tree structure generated
expected_tree = []
# this allows for verifying that a given node returns a given text. The
# text is compared with .startswith, so the entire text need not be matched.
expected_node_texts = {}
# just check the number of options from each node
expected_node_options_count = {}
# check the actual options
expected_node_options = {}
# set this to print the traversal as it happens (debugging)
debug_output = False
def _debug_output(self, indent, msg):
if self.debug_output:
print(" " * indent + msg)
def _test_menutree(self, menu):
"""
This is a automatic tester of the menu tree by recursively progressing through the
structure.
"""
def _depth_first(menu, tree, visited, indent):
# we are in a given node here
nodename = menu.nodename
options = menu.test_options
if isinstance(options, dict):
options = (options, )
# run validation tests for this node
compare_text = self.expected_node_texts.get(nodename, None)
if compare_text is not None:
compare_text = ansi.strip_ansi(compare_text.strip())
node_text = menu.test_nodetext
self.assertIsNotNone(
bool(node_text),
"node: {}: node-text is None, which was not expected.".format(nodename))
node_text = ansi.strip_ansi(node_text.strip())
self.assertTrue(
node_text.startswith(compare_text),
"\nnode \"{}\':\nOutput:\n{}\n\nExpected (startswith):\n{}".format(
nodename, node_text, compare_text))
compare_options_count = self.expected_node_options_count.get(nodename, None)
if compare_options_count is not None:
self.assertEqual(
len(options), compare_options_count,
"Not the right number of options returned from node {}.".format(nodename))
compare_options = self.expected_node_options.get(nodename, None)
if compare_options:
self.assertEqual(
options, compare_options,
"Options returned from node {} does not match.".format(nodename))
self._debug_output(indent, "*{}".format(nodename))
subtree = []
if not options:
# an end node
if nodename not in visited:
visited.append(nodename)
subtree = nodename
else:
for inum, optdict in enumerate(options):
key, desc, execute, goto = optdict.get("key", ""), optdict.get("desc", None),\
optdict.get("exec", None), optdict.get("goto", None)
# prepare the key to pass to the menu
if isinstance(key, (tuple, list)) and len(key) > 1:
key = key[0]
if key == "_default":
key = "test raw input"
if not key:
key = str(inum + 1)
backup_menu = copy.copy(menu)
# step the menu
menu.parse_input(key)
# from here on we are likely in a different node
nodename = menu.nodename
if menu.close_menu.called:
# this was an end node
self._debug_output(indent, " .. menu exited! Back to previous node.")
menu = backup_menu
menu.close_menu = MagicMock()
visited.append(nodename)
subtree.append(nodename)
elif nodename not in visited:
visited.append(nodename)
subtree.append(nodename)
_depth_first(menu, subtree, visited, indent + 2)
#self._debug_output(indent, " -> arrived at {}".format(nodename))
else:
subtree.append(nodename)
#self._debug_output( indent, " -> arrived at {} (circular call)".format(nodename))
self._debug_output(indent, "-- {} ({}) -> {}".format(key, desc, goto))
if subtree:
tree.append(subtree)
# the start node has already fired at this point
visited_nodes = [menu.nodename]
traversal_tree = [menu.nodename]
_depth_first(menu, traversal_tree, visited_nodes, 1)
if self.expect_all_nodes:
self.assertGreaterEqual(len(menu._menutree), len(visited_nodes))
self.assertEqual(traversal_tree, self.expected_tree)
def setUp(self):
self.caller = Mock()
self.caller.msg = Mock()
self.menu = evmenu.EvMenu(self.caller, "evennia.utils.evmenu", startnode="test_start_node",
persistent=True, cmdset_mergetype="Replace", testval="val",
testval2="val2")
self.menu = None
if self.menutree:
self.caller = MagicMock()
self.caller.key = "Test"
self.caller2 = MagicMock()
self.caller2.key = "Test"
self.caller.msg = MagicMock()
self.caller2.msg = MagicMock()
self.session = MagicMock()
self.session2 = MagicMock()
self.menu = evmenu.EvMenu(self.caller, self.menutree, startnode=self.startnode,
cmdset_mergetype=self.cmdset_mergetype,
cmdset_priority=self.cmdset_priority,
auto_quit=self.auto_quit, auto_look=self.auto_look,
auto_help=self.auto_help,
cmd_on_exit=self.cmd_on_exit, persistent=False,
startnode_input=self.startnode_input, session=self.session,
**self.kwargs)
# persistent version
self.pmenu = evmenu.EvMenu(self.caller2, self.menutree, startnode=self.startnode,
cmdset_mergetype=self.cmdset_mergetype,
cmdset_priority=self.cmdset_priority,
auto_quit=self.auto_quit, auto_look=self.auto_look,
auto_help=self.auto_help,
cmd_on_exit=self.cmd_on_exit, persistent=True,
startnode_input=self.startnode_input, session=self.session2,
**self.kwargs)
self.menu.close_menu = MagicMock()
self.pmenu.close_menu = MagicMock()
def test_menu_structure(self):
if self.menu:
self._test_menutree(self.menu)
self._test_menutree(self.pmenu)
class TestEvMenuExample(TestEvMenu):
menutree = "evennia.utils.evmenu"
startnode = "test_start_node"
kwargs = {"testval": "val", "testval2": "val2"}
debug_output = False
expected_node_texts = {
"test_view_node": "Your name is"}
expected_tree = \
['test_start_node',
['test_set_node',
['test_start_node'],
'test_look_node',
['test_start_node'],
'test_view_node',
['test_start_node'],
'test_dynamic_node',
['test_dynamic_node',
'test_dynamic_node',
'test_dynamic_node',
'test_dynamic_node',
'test_start_node'],
'test_end_node',
'test_displayinput_node',
['test_start_node']]]
def test_kwargsave(self):
self.assertTrue(hasattr(self.menu, "testval"))

View file

@ -1786,8 +1786,12 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs):
error = kwargs.get("nofound_string") or _("Could not find '%s'." % query)
matches = None
elif len(matches) > 1:
error = kwargs.get("multimatch_string") or \
_("More than one match for '%s' (please narrow target):\n" % query)
multimatch_string = kwargs.get("multimatch_string")
if multimatch_string:
error = "%s\n" % multimatch_string
else:
error = _("More than one match for '%s' (please narrow target):\n" % query)
for num, result in enumerate(matches):
# we need to consider Commands, where .aliases is a list
aliases = result.aliases.all() if hasattr(result.aliases, "all") else result.aliases

View file

@ -6,6 +6,7 @@
# tuple.
#
import os
from django.conf import settings
from evennia.utils.utils import get_evennia_version
@ -52,7 +53,11 @@ def set_webclient_settings():
global WEBCLIENT_ENABLED, WEBSOCKET_CLIENT_ENABLED, WEBSOCKET_PORT, WEBSOCKET_URL
WEBCLIENT_ENABLED = settings.WEBCLIENT_ENABLED
WEBSOCKET_CLIENT_ENABLED = settings.WEBSOCKET_CLIENT_ENABLED
WEBSOCKET_PORT = settings.WEBSOCKET_CLIENT_PORT
# if we are working through a proxy or uses docker port-remapping, the webclient port encoded
# in the webclient should be different than the one the server expects. Use the environment
# variable WEBSOCKET_CLIENT_PROXY_PORT if this is the case.
WEBSOCKET_PORT = int(os.environ.get("WEBSOCKET_CLIENT_PROXY_PORT", settings.WEBSOCKET_CLIENT_PORT))
# this is determined dynamically by the client and is less of an issue
WEBSOCKET_URL = settings.WEBSOCKET_CLIENT_URL
set_webclient_settings()

View file

@ -51,9 +51,9 @@ class TestGeneralContext(TestCase):
mock_settings.WEBCLIENT_ENABLED = "webclient"
mock_settings.WEBSOCKET_CLIENT_URL = "websocket_url"
mock_settings.WEBSOCKET_CLIENT_ENABLED = "websocket_client"
mock_settings.WEBSOCKET_CLIENT_PORT = "websocket_port"
mock_settings.WEBSOCKET_CLIENT_PORT = 5000
general_context.set_webclient_settings()
self.assertEqual(general_context.WEBCLIENT_ENABLED, "webclient")
self.assertEqual(general_context.WEBSOCKET_URL, "websocket_url")
self.assertEqual(general_context.WEBSOCKET_CLIENT_ENABLED, "websocket_client")
self.assertEqual(general_context.WEBSOCKET_PORT, "websocket_port")
self.assertEqual(general_context.WEBSOCKET_PORT, 5000)

View file

@ -377,7 +377,10 @@ function onNewLine(text, originator) {
document.title = "(" + unread + ") " + originalTitle;
if ("Notification" in window){
if (("notification_popup" in options) && (options["notification_popup"])) {
Notification.requestPermission().then(function(result) {
// There is a Promise-based API for this, but its not supported
// in Safari and some older browsers:
// https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission#Browser_compatibility
Notification.requestPermission(function(result) {
if(result === "granted") {
var title = originalTitle === "" ? "Evennia" : originalTitle;
var options = {

View file

@ -44,7 +44,7 @@ JQuery available.
{% endif %}
{% if websocket_url %}
var wsurl = "{{websocket_url}}:{{websocket_port}}";
var wsurl = "{{websocket_url}}";
{% else %}
var wsurl = "ws://" + this.location.hostname + ":{{websocket_port}}";
{% endif %}