diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cecd0de21..a05d65fdc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/Dockerfile b/Dockerfile index 27af6b2a3a..4ca00d254b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/bin/unix/evennia-docker-start.sh b/bin/unix/evennia-docker-start.sh new file mode 100644 index 0000000000..270f6ec627 --- /dev/null +++ b/bin/unix/evennia-docker-start.sh @@ -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 diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 43dfe71795..9feed46d2e 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -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, diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index e45e2a9347..90c2fdb954 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -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)) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index f4a001e6d2..5076170957 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -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 diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index 6a30e3813c..2f880f3f7f 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -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) diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index 429948b0fb..67e15dfd38 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -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: diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md index ed3c048c32..52e63b7b1a 100644 --- a/evennia/contrib/README.md +++ b/evennia/contrib/README.md @@ -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 diff --git a/evennia/contrib/email_login.py b/evennia/contrib/email_login.py index 01d08abab4..c98bf4aa88 100644 --- a/evennia/contrib/email_login.py +++ b/evennia/contrib/email_login.py @@ -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) diff --git a/evennia/contrib/health_bar.py b/evennia/contrib/health_bar.py new file mode 100644 index 0000000000..c3d4af1c52 --- /dev/null +++ b/evennia/contrib/health_bar.py @@ -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 diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 23e4662ec3..18ef0924be 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -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 diff --git a/evennia/contrib/tree_select.py b/evennia/contrib/tree_select.py new file mode 100644 index 0000000000..d2854fc83f --- /dev/null +++ b/evennia/contrib/tree_select.py @@ -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") + diff --git a/evennia/contrib/turnbattle/README.md b/evennia/contrib/turnbattle/README.md new file mode 100644 index 0000000000..729c42a099 --- /dev/null +++ b/evennia/contrib/turnbattle/README.md @@ -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. diff --git a/evennia/contrib/turnbattle/__init__.py b/evennia/contrib/turnbattle/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/evennia/contrib/turnbattle/__init__.py @@ -0,0 +1 @@ + diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle/tb_basic.py similarity index 88% rename from evennia/contrib/turnbattle.py rename to evennia/contrib/turnbattle/tb_basic.py index 692656bc3a..88e5c176c8 100644 --- a/evennia/contrib/turnbattle.py +++ b/evennia/contrib/turnbattle/tb_basic.py @@ -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()) \ No newline at end of file diff --git a/evennia/contrib/turnbattle/tb_equip.py b/evennia/contrib/turnbattle/tb_equip.py new file mode 100644 index 0000000000..b6b7ae6035 --- /dev/null +++ b/evennia/contrib/turnbattle/tb_equip.py @@ -0,0 +1,1086 @@ +""" +Simple turn-based combat system with equipment + +Contrib - Tim Ashley Jenkins 2017 + +This is a version of the 'turnbattle' contrib with a basic system for +weapons and armor implemented. Weapons can have unique damage ranges +and accuracy modifiers, while armor can reduce incoming damage and +change one's chance of getting hit. The 'wield' command is used to +equip weapons and the 'don' command is used to equip armor. + +Some prototypes are included at the end of this module - feel free to +copy them into your game's prototypes.py module in your 'world' folder +and create them with the @spawn command. (See the tutorial for using +the @spawn command for details.) + +For the example equipment given, heavier weapons deal more damage +but are less accurate, while light weapons are more accurate but +deal less damage. Similarly, heavy armor reduces incoming damage by +a lot but increases your chance of getting hit, while light armor is +easier to dodge in but reduces incoming damage less. Light weapons are +more effective against lightly armored opponents and heavy weapons are +more damaging against heavily armored foes, but heavy weapons and armor +are slightly better than light weapons and armor overall. + +This is a fairly bare implementation of equipment that is meant to be +expanded to fit your game - weapon and armor slots, damage types and +damage bonuses, etc. should be fairly simple to implement according to +the rules of your preferred system or the needs of your own game. + +To install and test, import this module's TBEquipCharacter object into +your game's character.py module: + + from evennia.contrib.turnbattle.tb_equip import TBEquipCharacter + +And change your game's character typeclass to inherit from TBEquipCharacter +instead of the default: + + class Character(TBEquipCharacter): + +Next, import this module into your default_cmdsets.py module: + + from evennia.contrib.turnbattle import tb_equip + +And add the battle command set to your default command set: + + # + # any commands you add below will overload the default ones. + # + self.add(tb_equip.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 +in your game and using it as-is. +""" + +from random import randint +from evennia import DefaultCharacter, Command, default_cmds, DefaultScript, DefaultObject +from evennia.commands.default.help import CmdHelp + +""" +---------------------------------------------------------------------------- +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): + """ + Rolls a number between 1-1000 to determine initiative. + + Args: + character (obj): The character to determine initiative for + + Returns: + initiative (int): The character's place in initiative - higher + numbers go first. + + Notes: + By default, does not reference the character and simply returns + a random integer from 1 to 1000. + + Since the character is passed to this function, you can easily reference + a character's stats to determine an initiative roll - for example, if your + character has a 'dexterity' attribute, you can use it to give that character + an advantage in turn order, like so: + + return (randint(1,20)) + character.db.dexterity + + This way, characters with a higher dexterity will go first more often. + """ + return randint(1, 1000) + + +def get_attack(attacker, defender): + """ + Returns a value for an attack roll. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + attack_value (int): Attack roll value, compared against a defense value + to determine whether an attack hits or misses. + + Notes: + In this example, a weapon's accuracy bonus is factored into the attack + roll. Lighter weapons are more accurate but less damaging, and heavier + weapons are less accurate but deal more damage. Of course, you can + change this paradigm completely in your own game. + """ + # Start with a roll from 1 to 100. + attack_value = randint(1, 100) + accuracy_bonus = 0 + # If armed, add weapon's accuracy bonus. + if attacker.db.wielded_weapon: + weapon = attacker.db.wielded_weapon + accuracy_bonus += weapon.db.accuracy_bonus + # If unarmed, use character's unarmed accuracy bonus. + else: + accuracy_bonus += attacker.db.unarmed_accuracy + # Add the accuracy bonus to the attack roll. + attack_value += accuracy_bonus + return attack_value + + +def get_defense(attacker, defender): + """ + Returns a value for defense, which an attack roll must equal or exceed in order + for an attack to hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + defense_value (int): Defense value, compared against an attack roll + to determine whether an attack hits or misses. + + Notes: + Characters are given a default defense value of 50 which can be + modified up or down by armor. In this example, wearing armor actually + makes you a little easier to hit, but reduces incoming damage. + """ + # Start with a defense value of 50 for a 50/50 chance to hit. + defense_value = 50 + # Modify this value based on defender's armor. + if defender.db.worn_armor: + armor = defender.db.worn_armor + defense_value += armor.db.defense_modifier + return defense_value + + +def get_damage(attacker, defender): + """ + Returns a value for damage to be deducted from the defender's HP after abilities + successful hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being damaged + + Returns: + damage_value (int): Damage value, which is to be deducted from the defending + character's HP. + + Notes: + Damage is determined by the attacker's wielded weapon, or the attacker's + unarmed damage range if no weapon is wielded. Incoming damage is reduced + by the defender's armor. + """ + damage_value = 0 + # Generate a damage value from wielded weapon if armed + if attacker.db.wielded_weapon: + weapon = attacker.db.wielded_weapon + # Roll between minimum and maximum damage + damage_value = randint(weapon.db.damage_range[0], weapon.db.damage_range[1]) + # Use attacker's unarmed damage otherwise + else: + damage_value = randint(attacker.db.unarmed_damage_range[0], attacker.db.unarmed_damage_range[1]) + # If defender is armored, reduce incoming damage + if defender.db.worn_armor: + armor = defender.db.worn_armor + damage_value -= armor.db.damage_reduction + # Make sure minimum damage is 0 + if damage_value < 0: + damage_value = 0 + return damage_value + + +def apply_damage(defender, damage): + """ + Applies damage to a target, reducing their HP by the damage amount to a + minimum of 0. + + Args: + defender (obj): Character taking damage + damage (int): Amount of damage being taken + """ + defender.db.hp -= damage # Reduce defender's HP by the damage dealt. + # If this reduces it to 0 or less, set HP to 0. + 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): + """ + Resolves an attack and outputs the result. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Notes: + Even though the attack and defense values are calculated + extremely simply, they are separated out into their own functions + so that they are easier to expand upon. + """ + # Get the attacker's weapon type to reference in combat messages. + attackers_weapon = "attack" + if attacker.db.wielded_weapon: + weapon = attacker.db.wielded_weapon + attackers_weapon = weapon.db.weapon_type_name + # Get an attack roll from the attacker. + if not attack_value: + attack_value = get_attack(attacker, defender) + # Get a defense value from the defender. + if not defense_value: + defense_value = get_defense(attacker, defender) + # If the attack value is lower than the defense value, miss. Otherwise, hit. + if attack_value < defense_value: + attacker.location.msg_contents("%s's %s misses %s!" % (attacker, attackers_weapon, defender)) + else: + damage_value = get_damage(attacker, defender) # Calculate damage value. + # Announce damage dealt and apply damage. + if damage_value > 0: + attacker.location.msg_contents("%s's %s strikes %s for %i damage!" % (attacker, attackers_weapon, defender, damage_value)) + else: + attacker.location.msg_contents("%s's %s bounces harmlessly off %s!" % (attacker, attackers_weapon, defender)) + apply_damage(defender, damage_value) + # If defender HP is reduced to 0 or less, call at_defeat. + if defender.db.hp <= 0: + at_defeat(defender) + + +def combat_cleanup(character): + """ + Cleans up all the temporary combat-related attributes on a character. + + Args: + character (obj): Character to have their combat attributes removed + + Notes: + Any attribute whose key begins with 'combat_' is temporary and no + longer needed once a fight ends. + """ + for attr in character.attributes.all(): + if attr.key[:7] == "combat_": # If the attribute name starts with 'combat_'... + character.attributes.remove(key=attr.key) # ...then delete it! + + +def is_in_combat(character): + """ + Returns true if the given character is in combat. + + Args: + character (obj): Character to determine if is in combat or not + + Returns: + (bool): True if in combat or False if not in combat + """ + return bool(character.db.combat_turnhandler) + + +def is_turn(character): + """ + Returns true if it's currently the given character's turn in combat. + + Args: + character (obj): Character to determine if it is their turn or not + + Returns: + (bool): True if it is their turn or False otherwise + """ + turnhandler = character.db.combat_turnhandler + currentchar = turnhandler.db.fighters[turnhandler.db.turn] + return bool(character == currentchar) + + +def spend_action(character, actions, action_name=None): + """ + Spends a character's available combat actions and checks for end of turn. + + Args: + character (obj): Character spending the action + actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions + + Kwargs: + action_name (str or None): If a string is given, sets character's last action in + combat to provided string + """ + if action_name: + character.db.combat_lastaction = action_name + if actions == 'all': # If spending all actions + 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. + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +class TBEquipTurnHandler(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) + +""" +---------------------------------------------------------------------------- +TYPECLASSES START HERE +---------------------------------------------------------------------------- +""" + +class TBEWeapon(DefaultObject): + """ + A weapon which can be wielded in combat with the 'wield' command. + """ + def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.damage_range = (15, 25) # Minimum and maximum damage on hit + self.db.accuracy_bonus = 0 # Bonus to attack rolls (or penalty if negative) + self.db.weapon_type_name = "weapon" # Single word for weapon - I.E. "dagger", "staff", "scimitar" + def at_drop(self, dropper): + """ + Stop being wielded if dropped. + """ + if dropper.db.wielded_weapon == self: + dropper.db.wielded_weapon = None + dropper.location.msg_contents("%s stops wielding %s." % (dropper, self)) + def at_give(self, giver, getter): + """ + Stop being wielded if given. + """ + if giver.db.wielded_weapon == self: + giver.db.wielded_weapon = None + giver.location.msg_contents("%s stops wielding %s." % (giver, self)) + +class TBEArmor(DefaultObject): + """ + A set of armor which can be worn with the 'don' command. + """ + def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.damage_reduction = 4 # Amount of incoming damage reduced by armor + self.db.defense_modifier = -4 # Amount to modify defense value (pos = harder to hit, neg = easier) + def at_before_drop(self, dropper): + """ + Can't drop in combat. + """ + if is_in_combat(dropper): + dropper.msg("You can't doff armor in a fight!") + return False + return True + def at_drop(self, dropper): + """ + Stop being wielded if dropped. + """ + if dropper.db.worn_armor == self: + dropper.db.worn_armor = None + dropper.location.msg_contents("%s removes %s." % (dropper, self)) + def at_before_give(self, giver, getter): + """ + Can't give away in combat. + """ + if is_in_combat(giver): + dropper.msg("You can't doff armor in a fight!") + return False + return True + def at_give(self, giver, getter): + """ + Stop being wielded if given. + """ + if giver.db.worn_armor == self: + giver.db.worn_armor = None + giver.location.msg_contents("%s removes %s." % (giver, self)) + +class TBEquipCharacter(DefaultCharacter): + """ + A character able to participate in turn-based combat. Has attributes for current + and maximum HP, and access to combat commands. + """ + + def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.wielded_weapon = None # Currently used weapon + self.db.worn_armor = None # Currently worn armor + self.db.unarmed_damage_range = (5, 15) # Minimum and maximum unarmed damage + self.db.unarmed_accuracy = 30 # Accuracy bonus for unarmed attacks + + """ + Adds attributes for a character's current and maximum HP. + We're just going to set this value at '100' by default. + + You may want to expand this to include various 'stats' that + can be changed at creation and factor into combat calculations. + """ + + def at_before_move(self, destination): + """ + Called just before starting to move this object to + destination. + + Args: + destination (Object): The object we are moving to + + Returns: + shouldmove (bool): If we should move or not. + + Notes: + If this method returns False/None, the move is cancelled + before it is even started. + + """ + # Keep the character from moving if at 0 HP or in combat. + if is_in_combat(self): + self.msg("You can't exit a room while in combat!") + return False # Returning false keeps the character from moving. + if self.db.HP <= 0: + self.msg("You can't move, you've been defeated!") + return False + return True + + +""" +---------------------------------------------------------------------------- +COMMANDS START HERE +---------------------------------------------------------------------------- +""" + + +class CmdFight(Command): + """ + Starts a fight with everyone in the same room as you. + + Usage: + fight + + When you start a fight, everyone in the room who is able to + fight is added to combat, and a turn order is randomly rolled. + When it's your turn, you can attack other characters. + """ + key = "fight" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + here = self.caller.location + fighters = [] + + if not self.caller.db.hp: # If you don't have any hp + self.caller.msg("You can't start a fight if you've been defeated!") + return + if is_in_combat(self.caller): # Already in a fight + self.caller.msg("You're already in a fight!") + return + for thing in here.contents: # Test everything in the room to add it to the fight. + if thing.db.HP: # If the object has HP... + fighters.append(thing) # ...then add it to the fight. + 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... + here.msg_contents("%s joins the fight!" % self.caller) + 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.tb_equip.TBEquipTurnHandler") + # Remember you'll have to change the path to the script if you copy this code to your own modules! + + +class CmdAttack(Command): + """ + Attacks another character. + + Usage: + attack + + When in a fight, you may attack another character. The attack has + a chance to hit, and if successful, will deal damage. + """ + + key = "attack" + help_category = "combat" + + def func(self): + "This performs the actual command." + "Set the attacker to the caller and the defender to the target." + + if not is_in_combat(self.caller): # If not in combat, can't attack. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't attack. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't attack if you have no HP. + self.caller.msg("You can't attack, you've been defeated.") + return + + attacker = self.caller + defender = self.caller.search(self.args) + + if not defender: # No valid target given. + return + + if not defender.db.hp: # Target object has no HP left or to begin with + self.caller.msg("You can't fight that!") + return + + if attacker == defender: # Target and attacker are the same + self.caller.msg("You can't attack yourself!") + return + + "If everything checks out, call the attack resolving function." + resolve_attack(attacker, defender) + spend_action(self.caller, 1, action_name="attack") # Use up one action. + + +class CmdPass(Command): + """ + Passes on your turn. + + Usage: + pass + + When in a fight, you can use this command to end your turn early, even + if there are still any actions you can take. + """ + + key = "pass" + aliases = ["wait", "hold"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # Can only pass a turn in combat. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # Can only pass if it's your turn. + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s takes no further action, passing the turn." % self.caller) + spend_action(self.caller, 'all', action_name="pass") # Spend all remaining actions. + + +class CmdDisengage(Command): + """ + Passes your turn and attempts to end combat. + + Usage: + disengage + + Ends your turn early and signals that you're trying to end + the fight. If all participants in a fight disengage, the + fight ends. + """ + + key = "disengage" + aliases = ["spare"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # If you're not in combat + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s disengages, ready to stop fighting." % self.caller) + spend_action(self.caller, 'all', action_name="disengage") # Spend all remaining actions. + """ + The action_name kwarg sets the character's last action to "disengage", which is checked by + the turn handler script to see if all fighters have disengaged. + """ + + +class CmdRest(Command): + """ + Recovers damage. + + Usage: + rest + + Resting recovers your HP to its maximum, but you can only + rest if you're not in a fight. + """ + + key = "rest" + help_category = "combat" + + def func(self): + "This performs the actual command." + + if is_in_combat(self.caller): # If you're in combat + self.caller.msg("You can't rest while you're in combat.") + return + + self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum + self.caller.location.msg_contents("%s rests to recover HP." % self.caller) + """ + You'll probably want to replace this with your own system for recovering HP. + """ + + +class CmdCombatHelp(CmdHelp): + """ + View help or a list of topics + + Usage: + help + help list + help all + + This will search for help on commands and other + topics related to the game. + """ + # Just like the default help command, but will give quick + # tips on combat when used in a fight with no arguments. + + def func(self): + if is_in_combat(self.caller) and not self.args: # In combat and entered 'help' alone + self.caller.msg("Available combat commands:|/" + + "|wAttack:|n Attack a target, attempting to deal damage.|/" + + "|wPass:|n Pass your turn without further action.|/" + + "|wDisengage:|n End your turn and attempt to end combat.|/") + else: + super(CmdCombatHelp, self).func() # Call the default help command + +class CmdWield(Command): + """ + Wield a weapon you are carrying + + Usage: + wield + + Select a weapon you are carrying to wield in combat. If + you are already wielding another weapon, you will switch + to the weapon you specify instead. Using this command in + combat will spend your action for your turn. Use the + "unwield" command to stop wielding any weapon you are + currently wielding. + """ + + key = "wield" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + # If in combat, check to see if it's your turn. + if is_in_combat(self.caller): + if not is_turn(self.caller): + self.caller.msg("You can only do that on your turn.") + return + if not self.args: + self.caller.msg("Usage: wield ") + return + weapon = self.caller.search(self.args, candidates=self.caller.contents) + if not weapon: + return + if not weapon.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEWeapon"): + self.caller.msg("That's not a weapon!") + # Remember to update the path to the weapon typeclass if you move this module! + return + + if not self.caller.db.wielded_weapon: + self.caller.db.wielded_weapon = weapon + self.caller.location.msg_contents("%s wields %s." % (self.caller, weapon)) + else: + old_weapon = self.caller.db.wielded_weapon + self.caller.db.wielded_weapon = weapon + self.caller.location.msg_contents("%s lowers %s and wields %s." % (self.caller, old_weapon, weapon)) + # Spend an action if in combat. + if is_in_combat(self.caller): + spend_action(self.caller, 1, action_name="wield") # Use up one action. + +class CmdUnwield(Command): + """ + Stop wielding a weapon. + + Usage: + unwield + + After using this command, you will stop wielding any + weapon you are currently wielding and become unarmed. + """ + + key = "unwield" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + # If in combat, check to see if it's your turn. + if is_in_combat(self.caller): + if not is_turn(self.caller): + self.caller.msg("You can only do that on your turn.") + return + if not self.caller.db.wielded_weapon: + self.caller.msg("You aren't wielding a weapon!") + else: + old_weapon = self.caller.db.wielded_weapon + self.caller.db.wielded_weapon = None + self.caller.location.msg_contents("%s lowers %s." % (self.caller, old_weapon)) + +class CmdDon(Command): + """ + Don armor that you are carrying + + Usage: + don + + Select armor to wear in combat. You can't use this + command in the middle of a fight. Use the "doff" + command to remove any armor you are wearing. + """ + + key = "don" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + # Can't do this in combat + if is_in_combat(self.caller): + self.caller.msg("You can't don armor in a fight!") + return + if not self.args: + self.caller.msg("Usage: don ") + return + armor = self.caller.search(self.args, candidates=self.caller.contents) + if not armor: + return + if not armor.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEArmor"): + self.caller.msg("That's not armor!") + # Remember to update the path to the armor typeclass if you move this module! + return + + if not self.caller.db.worn_armor: + self.caller.db.worn_armor = armor + self.caller.location.msg_contents("%s dons %s." % (self.caller, armor)) + else: + old_armor = self.caller.db.worn_armor + self.caller.db.worn_armor = armor + self.caller.location.msg_contents("%s removes %s and dons %s." % (self.caller, old_armor, armor)) + +class CmdDoff(Command): + """ + Stop wearing armor. + + Usage: + doff + + After using this command, you will stop wearing any + armor you are currently using and become unarmored. + You can't use this command in combat. + """ + + key = "doff" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + # Can't do this in combat + if is_in_combat(self.caller): + self.caller.msg("You can't doff armor in a fight!") + return + if not self.caller.db.worn_armor: + self.caller.msg("You aren't wearing any armor!") + else: + old_armor = self.caller.db.worn_armor + self.caller.db.worn_armor = None + self.caller.location.msg_contents("%s removes %s." % (self.caller, old_armor)) + + + +class BattleCmdSet(default_cmds.CharacterCmdSet): + """ + This command set includes all the commmands used in the battle system. + """ + key = "DefaultCharacter" + + def at_cmdset_creation(self): + """ + Populates the cmdset + """ + self.add(CmdFight()) + self.add(CmdAttack()) + self.add(CmdRest()) + self.add(CmdPass()) + self.add(CmdDisengage()) + self.add(CmdCombatHelp()) + self.add(CmdWield()) + self.add(CmdUnwield()) + self.add(CmdDon()) + self.add(CmdDoff()) + +""" +---------------------------------------------------------------------------- +PROTOTYPES START HERE +---------------------------------------------------------------------------- +""" + +BASEWEAPON = { + "typeclass": "evennia.contrib.turnbattle.tb_equip.TBEWeapon", +} + +BASEARMOR = { + "typeclass": "evennia.contrib.turnbattle.tb_equip.TBEArmor", +} + +DAGGER = { + "prototype" : "BASEWEAPON", + "damage_range" : (10, 20), + "accuracy_bonus" : 30, + "key": "a thin steel dagger", + "weapon_type_name" : "dagger" +} + +BROADSWORD = { + "prototype" : "BASEWEAPON", + "damage_range" : (15, 30), + "accuracy_bonus" : 15, + "key": "an iron broadsword", + "weapon_type_name" : "broadsword" +} + +GREATSWORD = { + "prototype" : "BASEWEAPON", + "damage_range" : (20, 40), + "accuracy_bonus" : 0, + "key": "a rune-etched greatsword", + "weapon_type_name" : "greatsword" +} + +LEATHERARMOR = { + "prototype" : "BASEARMOR", + "damage_reduction" : 2, + "defense_modifier" : -2, + "key": "a suit of leather armor" +} + +SCALEMAIL = { + "prototype" : "BASEARMOR", + "damage_reduction" : 4, + "defense_modifier" : -4, + "key": "a suit of scale mail" +} + +PLATEMAIL = { + "prototype" : "BASEARMOR", + "damage_reduction" : 6, + "defense_modifier" : -6, + "key": "a suit of plate mail" +} diff --git a/evennia/contrib/turnbattle/tb_range.py b/evennia/contrib/turnbattle/tb_range.py new file mode 100644 index 0000000000..5596573a2d --- /dev/null +++ b/evennia/contrib/turnbattle/tb_range.py @@ -0,0 +1,1383 @@ +""" +Simple turn-based combat system with range and movement + +Contrib - Tim Ashley Jenkins 2017 + +This is a version of the 'turnbattle' contrib that includes a system +for abstract movement and positioning in combat, including distinction +between melee and ranged attacks. In this system, a fighter or object's +exact position is not recorded - only their relative distance to other +actors in combat. + +In this example, the distance between two objects in combat is expressed +as an integer value: 0 for "engaged" objects that are right next to each +other, 1 for "reach" which is for objects that are near each other but +not directly adjacent, and 2 for "range" for objects that are far apart. + +When combat starts, all fighters are at reach with each other and other +objects, and at range from any exits. On a fighter's turn, they can use +the "approach" command to move closer to an object, or the "withdraw" +command to move further away from an object, either of which takes an +action in combat. In this example, fighters are given two actions per +turn, allowing them to move and attack in the same round, or to attack +twice or move twice. + +When you move toward an object, you will also move toward anything else +that's close to your target - the same goes for moving away from a target, +which will also move you away from anything close to your target. Moving +toward one target may also move you away from anything you're already +close to, but withdrawing from a target will never inadvertently bring +you closer to anything else. + +In this example, there are two attack commands. 'Attack' can only hit +targets that are 'engaged' (range 0) with you. 'Shoot' can hit any target +on the field, but cannot be used if you are engaged with any other fighters. +In addition, strikes made with the 'attack' command are more accurate than +'shoot' attacks. This is only to provide an example of how melee and ranged +attacks can be made to work differently - you can, of course, modify this +to fit your rules system. + +When in combat, the ranges of objects are also accounted for - you can't +pick up an object unless you're engaged with it, and can't give an object +to another fighter without being engaged with them either. Dropped objects +are automatically assigned a range of 'engaged' with the fighter who dropped +them. Additionally, giving or getting an object will take an action in combat. +Dropping an object does not take an action, but can only be done on your turn. + +When combat ends, all range values are erased and all restrictions on getting +or getting objects are lifted - distances are no longer tracked and objects in +the same room can be considered to be in the same space, as is the default +behavior of Evennia and most MUDs. + +This system allows for strategies in combat involving movement and +positioning to be implemented in your battle system without the use of +a 'grid' of coordinates, which can be difficult and clunky to navigate +in text and disadvantageous to players who use screen readers. This loose, +narrative method of tracking position is based around how the matter is +handled in tabletop RPGs played without a grid - typically, a character's +exact position in a room isn't important, only their relative distance to +other actors. + +You may wish to expand this system with a method of distinguishing allies +from enemies (to prevent allied characters from blocking your ranged attacks) +as well as some method by which melee-focused characters can prevent enemies +from withdrawing or punish them from doing so, such as by granting "attacks of +opportunity" or something similar. If you wish, you can also expand the breadth +of values allowed for range - rather than just 0, 1, and 2, you can allow ranges +to go up to much higher values, and give attacks and movements more varying +values for distance for a more granular system. You may also want to implement +a system for fleeing or changing rooms in combat by approaching exits, which +are objects placed in the range field like any other. + +To install and test, import this module's TBRangeCharacter object into +your game's character.py module: + + from evennia.contrib.turnbattle.tb_range import TBRangeCharacter + +And change your game's character typeclass to inherit from TBRangeCharacter +instead of the default: + + class Character(TBRangeCharacter): + +Do the same thing in your game's objects.py module for TBRangeObject: + + from evennia.contrib.turnbattle.tb_range import TBRangeObject + class Object(TBRangeObject): + +Next, import this module into your default_cmdsets.py module: + + from evennia.contrib.turnbattle import tb_range + +And add the battle command set to your default command set: + + # + # any commands you add below will overload the default ones. + # + self.add(tb_range.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 +in your game and using it as-is. +""" + +from random import randint +from evennia import DefaultCharacter, DefaultObject, Command, default_cmds, DefaultScript +from evennia.commands.default.help import CmdHelp + +""" +---------------------------------------------------------------------------- +OPTIONS +---------------------------------------------------------------------------- +""" + +TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds +ACTIONS_PER_TURN = 2 # Number of actions allowed per turn + +""" +---------------------------------------------------------------------------- +COMBAT FUNCTIONS START HERE +---------------------------------------------------------------------------- +""" + +def roll_init(character): + """ + Rolls a number between 1-1000 to determine initiative. + + Args: + character (obj): The character to determine initiative for + + Returns: + initiative (int): The character's place in initiative - higher + numbers go first. + + Notes: + By default, does not reference the character and simply returns + a random integer from 1 to 1000. + + Since the character is passed to this function, you can easily reference + a character's stats to determine an initiative roll - for example, if your + character has a 'dexterity' attribute, you can use it to give that character + an advantage in turn order, like so: + + return (randint(1,20)) + character.db.dexterity + + This way, characters with a higher dexterity will go first more often. + """ + return randint(1, 1000) + + +def get_attack(attacker, defender, attack_type): + """ + Returns a value for an attack roll. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + attack_type (str): Type of attack ('melee' or 'ranged') + + Returns: + attack_value (int): Attack roll value, compared against a defense value + to determine whether an attack hits or misses. + + Notes: + By default, generates a random integer from 1 to 100 without using any + properties from either the attacker or defender, and modifies the result + based on whether it's for a melee or ranged attack. + + This can easily be expanded to return a value based on characters stats, + equipment, and abilities. This is why the attacker and defender are passed + to this function, even though nothing from either one are used in this example. + """ + # For this example, just return a random integer up to 100. + attack_value = randint(1, 100) + # Make melee attacks more accurate, ranged attacks less accurate + if attack_type == "melee": + attack_value += 15 + if attack_type == "ranged": + attack_value -= 15 + return attack_value + + +def get_defense(attacker, defender, attack_type): + """ + Returns a value for defense, which an attack roll must equal or exceed in order + for an attack to hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + attack_type (str): Type of attack ('melee' or 'ranged') + + Returns: + defense_value (int): Defense value, compared against an attack roll + to determine whether an attack hits or misses. + + Notes: + By default, returns 50, not taking any properties of the defender or + attacker into account. + + As above, this can be expanded upon based on character stats and equipment. + """ + # For this example, just return 50, for about a 50/50 chance of hit. + defense_value = 50 + return defense_value + + +def get_damage(attacker, defender): + """ + Returns a value for damage to be deducted from the defender's HP after abilities + successful hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being damaged + + Returns: + damage_value (int): Damage value, which is to be deducted from the defending + character's HP. + + Notes: + By default, returns a random integer from 15 to 25 without using any + properties from either the attacker or defender. + + Again, this can be expanded upon. + """ + # For this example, just generate a number between 15 and 25. + damage_value = randint(15, 25) + return damage_value + + +def apply_damage(defender, damage): + """ + Applies damage to a target, reducing their HP by the damage amount to a + minimum of 0. + + Args: + defender (obj): Character taking damage + damage (int): Amount of damage being taken + """ + defender.db.hp -= damage # Reduce defender's HP by the damage dealt. + # If this reduces it to 0 or less, set HP to 0. + 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_type, attack_value=None, defense_value=None): + """ + Resolves an attack and outputs the result. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + attack_type (str): Type of attack (melee or ranged) + + Notes: + Even though the attack and defense values are calculated + extremely simply, they are separated out into their own functions + so that they are easier to expand upon. + """ + # Get an attack roll from the attacker. + if not attack_value: + attack_value = get_attack(attacker, defender, attack_type) + # Get a defense value from the defender. + if not defense_value: + defense_value = get_defense(attacker, defender, attack_type) + # If the attack value is lower than the defense value, miss. Otherwise, hit. + if attack_value < defense_value: + attacker.location.msg_contents("%s's %s attack misses %s!" % (attacker, attack_type, defender)) + else: + damage_value = get_damage(attacker, defender) # Calculate damage value. + # Announce damage dealt and apply damage. + attacker.location.msg_contents("%s hits %s with a %s attack for %i damage!" % (attacker, defender, attack_type, damage_value)) + apply_damage(defender, damage_value) + # If defender HP is reduced to 0 or less, call at_defeat. + if defender.db.hp <= 0: + at_defeat(defender) + +def get_range(obj1, obj2): + """ + Gets the combat range between two objects. + + Args: + obj1 (obj): First object + obj2 (obj): Second object + + Returns: + range (int or None): Distance between two objects or None if not applicable + """ + # Return None if not applicable. + if not obj1.db.combat_range: + return None + if not obj2.db.combat_range: + return None + if obj1 not in obj2.db.combat_range: + return None + if obj2 not in obj1.db.combat_range: + return None + # Return the range between the two objects. + return obj1.db.combat_range[obj2] + +def distance_inc(mover, target): + """ + Function that increases distance in range field between mover and target. + + Args: + mover (obj): The object moving + target (obj): The object to be moved away from + """ + mover.db.combat_range[target] += 1 + target.db.combat_range[mover] = mover.db.combat_range[target] + # Set a cap of 2: + if get_range(mover, target) > 2: + target.db.combat_range[mover] = 2 + mover.db.combat_range[target] = 2 + +def approach(mover, target): + """ + Manages a character's whole approach, including changes in ranges to other characters. + + Args: + mover (obj): The object moving + target (obj): The object to be moved toward + + Notes: + The mover will also automatically move toward any objects that are closer to the + target than the mover is. The mover will also move away from anything they started + out close to. + """ + def distance_dec(mover, target): + """ + Helper function that decreases distance in range field between mover and target. + + Args: + mover (obj): The object moving + target (obj): The object to be moved toward + """ + mover.db.combat_range[target] -= 1 + target.db.combat_range[mover] = mover.db.combat_range[target] + # If this brings mover to range 0 (Engaged): + if get_range(mover, target) <= 0: + # Reset range to each other to 0 and copy target's ranges to mover. + target.db.combat_range[mover] = 0 + mover.db.combat_range = target.db.combat_range + # Assure everything else has the same distance from the mover and target, now that they're together + for thing in mover.location.contents: + if thing != mover and thing != target: + thing.db.combat_range[mover] = thing.db.combat_range[target] + + contents = mover.location.contents + + for thing in contents: + if thing != mover and thing != target: + # Move closer to each object closer to the target than you. + if get_range(mover, thing) > get_range(target, thing): + distance_dec(mover, thing) + # Move further from each object that's further from you than from the target. + if get_range(mover, thing) < get_range(target, thing): + distance_inc(mover, thing) + # Lastly, move closer to your target. + distance_dec(mover, target) + +def withdraw(mover, target): + """ + Manages a character's whole withdrawal, including changes in ranges to other characters. + + Args: + mover (obj): The object moving + target (obj): The object to be moved away from + + Notes: + The mover will also automatically move away from objects that are close to the target + of their withdrawl. The mover will never inadvertently move toward anything else while + withdrawing - they can be considered to be moving to open space. + """ + + contents = mover.location.contents + + for thing in contents: + if thing != mover and thing != target: + # Move away from each object closer to the target than you, if it's also closer to you than you are to the target. + if get_range(mover, thing) >= get_range(target, thing) and get_range(mover, thing) < get_range(mover, target): + distance_inc(mover, thing) + # Move away from anything your target is engaged with + if get_range(target, thing) == 0: + distance_inc(mover, thing) + # Move away from anything you're engaged with. + if get_range(mover, thing) == 0: + distance_inc(mover, thing) + # Then, move away from your target. + distance_inc(mover, target) + +def combat_cleanup(character): + """ + Cleans up all the temporary combat-related attributes on a character. + + Args: + character (obj): Character to have their combat attributes removed + + Notes: + Any attribute whose key begins with 'combat_' is temporary and no + longer needed once a fight ends. + """ + for attr in character.attributes.all(): + if attr.key[:7] == "combat_": # If the attribute name starts with 'combat_'... + character.attributes.remove(key=attr.key) # ...then delete it! + + +def is_in_combat(character): + """ + Returns true if the given character is in combat. + + Args: + character (obj): Character to determine if is in combat or not + + Returns: + (bool): True if in combat or False if not in combat + """ + return bool(character.db.combat_turnhandler) + + +def is_turn(character): + """ + Returns true if it's currently the given character's turn in combat. + + Args: + character (obj): Character to determine if it is their turn or not + + Returns: + (bool): True if it is their turn or False otherwise + """ + turnhandler = character.db.combat_turnhandler + currentchar = turnhandler.db.fighters[turnhandler.db.turn] + return bool(character == currentchar) + + +def spend_action(character, actions, action_name=None): + """ + Spends a character's available combat actions and checks for end of turn. + + Args: + character (obj): Character spending the action + actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions + + Kwargs: + action_name (str or None): If a string is given, sets character's last action in + combat to provided string + """ + if action_name: + character.db.combat_lastaction = action_name + if actions == 'all': # If spending all actions + 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. + +def combat_status_message(fighter): + """ + Sends a message to a player with their current HP and + distances to other fighters and objects. Called at turn + start and by the 'status' command. + """ + if not fighter.db.max_hp: + fighter.db.hp = 100 + fighter.db.max_hp = 100 + + status_msg = ("HP Remaining: %i / %i" % (fighter.db.hp, fighter.db.max_hp)) + + if not is_in_combat(fighter): + fighter.msg(status_msg) + return + + engaged_obj = [] + reach_obj = [] + range_obj = [] + + for thing in fighter.db.combat_range: + if thing != fighter: + if fighter.db.combat_range[thing] == 0: + engaged_obj.append(thing) + if fighter.db.combat_range[thing] == 1: + reach_obj.append(thing) + if fighter.db.combat_range[thing] > 1: + range_obj.append(thing) + + if engaged_obj: + status_msg += "|/Engaged targets: %s" % ", ".join(obj.key for obj in engaged_obj) + if reach_obj: + status_msg += "|/Reach targets: %s" % ", ".join(obj.key for obj in reach_obj) + if range_obj: + status_msg += "|/Ranged targets: %s" % ", ".join(obj.key for obj in range_obj) + + fighter.msg(status_msg) + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +class TBRangeTurnHandler(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 + + # Initialize range field for all objects in the room + for thing in self.obj.contents: + self.init_range(thing) + + # 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 thing in self.obj.contents: + combat_cleanup(thing) # Clean up the combat attributes for every object in the room. + 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 init_range(self, to_init): + """ + Initializes range values for an object at the start of a fight. + + Args: + to_init (object): Object to initialize range field for. + """ + rangedict = {} + # Get a list of objects in the room. + objectlist = self.obj.contents + for thing in objectlist: + # Object always at distance 0 from itself + if thing == to_init: + rangedict.update({thing:0}) + else: + if thing.destination or to_init.destination: + # Start exits at range 2 to put them at the 'edges' + rangedict.update({thing:2}) + else: + # Start objects at range 1 from other objects + rangedict.update({thing:1}) + to_init.db.combat_range = rangedict + + def join_rangefield(self, to_init, anchor_obj=None, add_distance=0): + """ + Adds a new object to the range field of a fight in progress. + + Args: + to_init (object): Object to initialize range field for. + + Kwargs: + anchor_obj (object): Object to copy range values from, or None for a random object. + add_distance (int): Distance to put between to_init object and anchor object. + + """ + # Get a list of room's contents without to_init object. + contents = self.obj.contents + contents.remove(to_init) + # If no anchor object given, pick one in the room at random. + if not anchor_obj: + anchor_obj = contents[randint(0, (len(contents)-1))] + # Copy the range values from the anchor object. + to_init.db.combat_range = anchor_obj.db.combat_range + # Add the new object to everyone else's ranges. + for thing in contents: + new_objects_range = thing.db.combat_range[anchor_obj] + thing.db.combat_range.update({to_init:new_objects_range}) + # Set the new object's range to itself to 0. + to_init.db.combat_range.update({to_init:0}) + # Add additional distance from anchor object, if any. + for n in range(add_distance): + withdraw(to_init, anchor_obj) + + 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: + In this example, characters are given two actions per turn. This allows + characters to both move and attack in the same turn (or, alternately, + move twice or attack twice). + """ + 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!|n") + combat_status_message(character) + + 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) + # Add the character to the rangefield, at range from everyone, if they're not on it already. + if not character.db.combat_range: + self.join_rangefield(character, add_distance=2) + + +""" +---------------------------------------------------------------------------- +TYPECLASSES START HERE +---------------------------------------------------------------------------- +""" + + +class TBRangeCharacter(DefaultCharacter): + """ + A character able to participate in turn-based combat. Has attributes for current + and maximum HP, and access to combat commands. + """ + + def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + """ + Adds attributes for a character's current and maximum HP. + We're just going to set this value at '100' by default. + + You may want to expand this to include various 'stats' that + can be changed at creation and factor into combat calculations. + """ + + def at_before_move(self, destination): + """ + Called just before starting to move this object to + destination. + + Args: + destination (Object): The object we are moving to + + Returns: + shouldmove (bool): If we should move or not. + + Notes: + If this method returns False/None, the move is cancelled + before it is even started. + + """ + # Keep the character from moving if at 0 HP or in combat. + if is_in_combat(self): + self.msg("You can't exit a room while in combat!") + return False # Returning false keeps the character from moving. + if self.db.HP <= 0: + self.msg("You can't move, you've been defeated!") + return False + return True + +class TBRangeObject(DefaultObject): + """ + An object that is assigned range values in combat. Getting, giving, and dropping + the object has restrictions in combat - you must be next to an object to get it, + must be next to your target to give them something, and can only interact with + objects on your own turn. + """ + def at_before_drop(self, dropper): + """ + 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. + + """ + # Can't drop something if in combat and it's not your turn + if is_in_combat(dropper) and not is_turn(dropper): + dropper.msg("You can only drop things on your turn!") + return False + return True + + def at_drop(self, dropper): + """ + Called by the default `drop` command when this object has been + dropped. + + Args: + dropper (Object): The object which just dropped this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + This hook cannot stop the drop from happening. Use + permissions or the at_before_drop() hook for that. + + """ + # If dropper is currently in combat + if dropper.location.db.combat_turnhandler: + # Object joins the range field + self.db.combat_range = {} + dropper.location.db.combat_turnhandler.join_rangefield(self, anchor_obj=dropper) + + def at_before_get(self, getter): + """ + 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. + """ + # Restrictions for getting in combat + if is_in_combat(getter): + if not is_turn(getter): # Not your turn + getter.msg("You can only get things on your turn!") + return False + if get_range(self, getter) > 0: # Too far away + getter.msg("You aren't close enough to get that! (see: help approach)") + return False + return True + + def at_get(self, getter): + """ + Called by the default `get` command when this object has been + picked up. + + Args: + getter (Object): The object getting this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + This hook cannot stop the pickup from happening. Use + permissions or the at_before_get() hook for that. + + """ + # If gotten, erase range values + if self.db.combat_range: + del self.db.combat_range + # Remove this object from everyone's range fields + for thing in getter.location.contents: + if thing.db.combat_range: + if self in thing.db.combat_range: + thing.db.combat_range.pop(self, None) + # If in combat, getter spends an action + if is_in_combat(getter): + spend_action(getter, 1, action_name="get") # Use up one action. + + def at_before_give(self, giver, getter): + """ + 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. + + """ + # Restrictions for giving in combat + if is_in_combat(giver): + if not is_turn(giver): # Not your turn + giver.msg("You can only give things on your turn!") + return False + if get_range(giver, getter) > 0: # Too far away from target + giver.msg("You aren't close enough to give things to %s! (see: help approach)" % getter) + return False + return True + + def at_give(self, giver, getter): + """ + Called by the default `give` command when this object has been + given. + + Args: + giver (Object): The object giving this object. + getter (Object): The object getting this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + This hook cannot stop the give from happening. Use + permissions or the at_before_give() hook for that. + + """ + # Spend an action if in combat + if is_in_combat(giver): + spend_action(giver, 1, action_name="give") # Use up one action. + +""" +---------------------------------------------------------------------------- +COMMANDS START HERE +---------------------------------------------------------------------------- +""" + + +class CmdFight(Command): + """ + Starts a fight with everyone in the same room as you. + + Usage: + fight + + When you start a fight, everyone in the room who is able to + fight is added to combat, and a turn order is randomly rolled. + When it's your turn, you can attack other characters. + """ + key = "fight" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + here = self.caller.location + fighters = [] + + if not self.caller.db.hp: # If you don't have any hp + self.caller.msg("You can't start a fight if you've been defeated!") + return + if is_in_combat(self.caller): # Already in a fight + self.caller.msg("You're already in a fight!") + return + for thing in here.contents: # Test everything in the room to add it to the fight. + if thing.db.HP: # If the object has HP... + fighters.append(thing) # ...then add it to the fight. + 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... + here.msg_contents("%s joins the fight!" % self.caller) + 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.tb_range.TBRangeTurnHandler") + # Remember you'll have to change the path to the script if you copy this code to your own modules! + + +class CmdAttack(Command): + """ + Attacks another character in melee. + + Usage: + attack + + When in a fight, you may attack another character. The attack has + a chance to hit, and if successful, will deal damage. You can only + attack engaged targets - that is, targets that are right next to + you. Use the 'approach' command to get closer to a target. + """ + + key = "attack" + help_category = "combat" + + def func(self): + "This performs the actual command." + "Set the attacker to the caller and the defender to the target." + + if not is_in_combat(self.caller): # If not in combat, can't attack. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't attack. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't attack if you have no HP. + self.caller.msg("You can't attack, you've been defeated.") + return + + attacker = self.caller + defender = self.caller.search(self.args) + + if not defender: # No valid target given. + return + + if not defender.db.hp: # Target object has no HP left or to begin with + self.caller.msg("You can't fight that!") + return + + if attacker == defender: # Target and attacker are the same + self.caller.msg("You can't attack yourself!") + return + + if not get_range(attacker, defender) == 0: # Target isn't in melee + self.caller.msg("%s is too far away to attack - you need to get closer! (see: help approach)" % defender) + return + + "If everything checks out, call the attack resolving function." + resolve_attack(attacker, defender, "melee") + spend_action(self.caller, 1, action_name="attack") # Use up one action. + +class CmdShoot(Command): + """ + Attacks another character from range. + + Usage: + shoot + + When in a fight, you may shoot another character. The attack has + a chance to hit, and if successful, will deal damage. You can attack + any target in combat by shooting, but can't shoot if there are any + targets engaged with you. Use the 'withdraw' command to retreat from + nearby enemies. + """ + + key = "shoot" + help_category = "combat" + + def func(self): + "This performs the actual command." + "Set the attacker to the caller and the defender to the target." + + if not is_in_combat(self.caller): # If not in combat, can't attack. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't attack. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't attack if you have no HP. + self.caller.msg("You can't attack, you've been defeated.") + return + + attacker = self.caller + defender = self.caller.search(self.args) + + if not defender: # No valid target given. + return + + if not defender.db.hp: # Target object has no HP left or to begin with + self.caller.msg("You can't fight that!") + return + + if attacker == defender: # Target and attacker are the same + self.caller.msg("You can't attack yourself!") + return + + # Test to see if there are any nearby enemy targets. + in_melee = [] + for target in attacker.db.combat_range: + # Object is engaged and has HP + if get_range(attacker, defender) == 0 and target.db.hp and target != self.caller: + in_melee.append(target) # Add to list of targets in melee + + if len(in_melee) > 0: + self.caller.msg("You can't shoot because there are fighters engaged with you (%s) - you need to retreat! (see: help withdraw)" % ", ".join(obj.key for obj in in_melee)) + return + + "If everything checks out, call the attack resolving function." + resolve_attack(attacker, defender, "ranged") + spend_action(self.caller, 1, action_name="attack") # Use up one action. + +class CmdApproach(Command): + """ + Approaches an object. + + Usage: + approach + + Move one space toward a character or object. You can only attack + characters you are 0 spaces away from. + """ + + key = "approach" + help_category = "combat" + + def func(self): + "This performs the actual command." + + if not is_in_combat(self.caller): # If not in combat, can't approach. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't approach. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't approach if you have no HP. + self.caller.msg("You can't move, you've been defeated.") + return + + mover = self.caller + target = self.caller.search(self.args) + + if not target: # No valid target given. + return + + if not target.db.combat_range: # Target object is not on the range field + self.caller.msg("You can't move toward that!") + return + + if mover == target: # Target and mover are the same + self.caller.msg("You can't move toward yourself!") + return + + if get_range(mover, target) <= 0: # Already engaged with target + self.caller.msg("You're already next to that target!") + return + + # If everything checks out, call the approach resolving function. + approach(mover, target) + mover.location.msg_contents("%s moves toward %s." % (mover, target)) + spend_action(self.caller, 1, action_name="move") # Use up one action. + +class CmdWithdraw(Command): + """ + Moves away from an object. + + Usage: + withdraw + + Move one space away from a character or object. + """ + + key = "withdraw" + help_category = "combat" + + def func(self): + "This performs the actual command." + + if not is_in_combat(self.caller): # If not in combat, can't withdraw. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't withdraw. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't withdraw if you have no HP. + self.caller.msg("You can't move, you've been defeated.") + return + + mover = self.caller + target = self.caller.search(self.args) + + if not target: # No valid target given. + return + + if not target.db.combat_range: # Target object is not on the range field + self.caller.msg("You can't move away from that!") + return + + if mover == target: # Target and mover are the same + self.caller.msg("You can't move away from yourself!") + return + + if mover.db.combat_range[target] >= 3: # Already at maximum distance + self.caller.msg("You're as far as you can get from that target!") + return + + # If everything checks out, call the approach resolving function. + withdraw(mover, target) + mover.location.msg_contents("%s moves away from %s." % (mover, target)) + spend_action(self.caller, 1, action_name="move") # Use up one action. + + +class CmdPass(Command): + """ + Passes on your turn. + + Usage: + pass + + When in a fight, you can use this command to end your turn early, even + if there are still any actions you can take. + """ + + key = "pass" + aliases = ["wait", "hold"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # Can only pass a turn in combat. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # Can only pass if it's your turn. + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s takes no further action, passing the turn." % self.caller) + spend_action(self.caller, 'all', action_name="pass") # Spend all remaining actions. + + +class CmdDisengage(Command): + """ + Passes your turn and attempts to end combat. + + Usage: + disengage + + Ends your turn early and signals that you're trying to end + the fight. If all participants in a fight disengage, the + fight ends. + """ + + key = "disengage" + aliases = ["spare"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # If you're not in combat + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s disengages, ready to stop fighting." % self.caller) + spend_action(self.caller, 'all', action_name="disengage") # Spend all remaining actions. + """ + The action_name kwarg sets the character's last action to "disengage", which is checked by + the turn handler script to see if all fighters have disengaged. + """ + + +class CmdRest(Command): + """ + Recovers damage. + + Usage: + rest + + Resting recovers your HP to its maximum, but you can only + rest if you're not in a fight. + """ + + key = "rest" + help_category = "combat" + + def func(self): + "This performs the actual command." + + if is_in_combat(self.caller): # If you're in combat + self.caller.msg("You can't rest while you're in combat.") + return + + self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum + self.caller.location.msg_contents("%s rests to recover HP." % self.caller) + """ + You'll probably want to replace this with your own system for recovering HP. + """ + +class CmdStatus(Command): + """ + Gives combat information. + + Usage: + status + + Shows your current and maximum HP and your distance from + other targets in combat. + """ + + key = "status" + help_category = "combat" + + def func(self): + "This performs the actual command." + combat_status_message(self.caller) + + +class CmdCombatHelp(CmdHelp): + """ + View help or a list of topics + + Usage: + help + help list + help all + + This will search for help on commands and other + topics related to the game. + """ + # Just like the default help command, but will give quick + # tips on combat when used in a fight with no arguments. + + def func(self): + if is_in_combat(self.caller) and not self.args: # In combat and entered 'help' alone + self.caller.msg("Available combat commands:|/" + + "|wAttack:|n Attack an engaged target, attempting to deal damage.|/" + + "|wShoot:|n Attack from a distance, if not engaged with other fighters.|/" + + "|wApproach:|n Move one step cloer to a target.|/" + + "|wWithdraw:|n Move one step away from a target.|/" + + "|wPass:|n Pass your turn without further action.|/" + + "|wStatus:|n View current HP and ranges to other targets.|/" + + "|wDisengage:|n End your turn and attempt to end combat.|/") + else: + super(CmdCombatHelp, self).func() # Call the default help command + + +class BattleCmdSet(default_cmds.CharacterCmdSet): + """ + This command set includes all the commmands used in the battle system. + """ + key = "DefaultCharacter" + + def at_cmdset_creation(self): + """ + Populates the cmdset + """ + self.add(CmdFight()) + self.add(CmdAttack()) + self.add(CmdShoot()) + self.add(CmdRest()) + self.add(CmdPass()) + self.add(CmdDisengage()) + self.add(CmdApproach()) + self.add(CmdWithdraw()) + self.add(CmdStatus()) + self.add(CmdCombatHelp()) \ No newline at end of file diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 1509e7ce08..af7f520817 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -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) diff --git a/evennia/scripts/manager.py b/evennia/scripts/manager.py index 17a11d20b3..1b795b2c3e 100644 --- a/evennia/scripts/manager.py +++ b/evennia/scripts/manager.py @@ -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 diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index e8c310cae1..79002c2a0e 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -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: diff --git a/evennia/server/portal/ssh.py b/evennia/server/portal/ssh.py index e993e74bc3..7dde511637 100644 --- a/evennia/server/portal/ssh.py +++ b/evennia/server/portal/ssh.py @@ -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 diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index bdb937bde5..a1877d15ee 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -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) diff --git a/evennia/server/server.py b/evennia/server/server.py index 27ca1fd0f8..fb9f753b5c 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -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: diff --git a/evennia/server/session.py b/evennia/server/session.py index 9853bcd366..7d2751200f 100644 --- a/evennia/server/session.py +++ b/evennia/server/session.py @@ -7,7 +7,6 @@ from builtins import object import time - #------------------------------------------------------------ # Server Session #------------------------------------------------------------ diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 7948868ed9..57628d8607 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -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 diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index d248910e76..a595366f5c 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -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 diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 3fe1af496e..86b24e5e4f 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -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! """ diff --git a/evennia/utils/tests/test_evmenu.py b/evennia/utils/tests/test_evmenu.py index 4436fdccd6..04310c90ed 100644 --- a/evennia/utils/tests/test_evmenu.py +++ b/evennia/utils/tests/test_evmenu.py @@ -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")) diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 1a159991ba..667f6a209b 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -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 diff --git a/evennia/web/utils/general_context.py b/evennia/web/utils/general_context.py index 27edd79c86..44bc8b3cb3 100644 --- a/evennia/web/utils/general_context.py +++ b/evennia/web/utils/general_context.py @@ -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() diff --git a/evennia/web/utils/tests.py b/evennia/web/utils/tests.py index b2d42891ae..e2b28c3510 100644 --- a/evennia/web/utils/tests.py +++ b/evennia/web/utils/tests.py @@ -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) diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js index 57d9b0b7c0..9dde7da801 100644 --- a/evennia/web/webclient/static/webclient/js/webclient_gui.js +++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js @@ -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 it’s 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 = { diff --git a/evennia/web/webclient/templates/webclient/base.html b/evennia/web/webclient/templates/webclient/base.html index 373ff0f357..30a68c498f 100644 --- a/evennia/web/webclient/templates/webclient/base.html +++ b/evennia/web/webclient/templates/webclient/base.html @@ -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 %}