diff --git a/CODING_STYLE.md b/CODING_STYLE.md index 79488e94ec..460dfc15d5 100644 --- a/CODING_STYLE.md +++ b/CODING_STYLE.md @@ -97,15 +97,15 @@ def funcname(a, b, c, d=False, **kwargs): Args: a (str): This is a string argument that we can talk about over multiple lines. - b (int or str): Another argument - c (list): A list argument - d (bool, optional): An optional keyword argument + b (int or str): Another argument. + c (list): A list argument. + d (bool, optional): An optional keyword argument. Kwargs: - test (list): A test keyword + test (list): A test keyword. Returns: - e (str): The result of the function + e (str): The result of the function. Raises: RuntimeException: If there is a critical error, diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index ea84a68bcc..c4c8c37df7 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -798,7 +798,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): # 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, - session=session)) + session=session), session=session) def at_failed_login(self, session, **kwargs): """ diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 709e7154ba..3fb762910d 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -33,7 +33,7 @@ from evennia.prototypes import prototypes as protlib # set up signal here since we are not starting the server -_RE = re.compile(r"^\+|-+\+|\+-+|--*|\|(?:\s|$)", re.MULTILINE) +_RE = re.compile(r"^\+|-+\+|\+-+|--+|\|(?:\s|$)", re.MULTILINE) # ------------------------------------------------------------ @@ -112,8 +112,11 @@ class CommandTest(EvenniaTest): # Get the first element of a tuple if msg received a tuple instead of a string stored_msg = [smsg[0] if isinstance(smsg, tuple) else smsg for smsg in stored_msg] if msg is not None: - returned_msg = "||".join(_RE.sub("", str(mess)) for mess in stored_msg) - returned_msg = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip() + # set our separator for returned messages based on parsing ansi or not + msg_sep = "|" if noansi else "||" + # Have to strip ansi for each returned message for the regex to handle it correctly + returned_msg = msg_sep.join(_RE.sub("", ansi.parse_ansi(mess, strip_ansi=noansi)) + for mess in stored_msg).strip() if msg == "" and returned_msg or not returned_msg.startswith(msg.strip()): sep1 = "\n" + "=" * 30 + "Wanted message" + "=" * 34 + "\n" sep2 = "\n" + "=" * 30 + "Returned message" + "=" * 32 + "\n" @@ -147,11 +150,11 @@ class TestGeneral(CommandTest): def test_nick(self): self.call(general.CmdNick(), "testalias = testaliasedstring1", - "Inputlinenick 'testalias' mapped to 'testaliasedstring1'.") + "Inputline-nick 'testalias' mapped to 'testaliasedstring1'.") self.call(general.CmdNick(), "/account testalias = testaliasedstring2", - "Accountnick 'testalias' mapped to 'testaliasedstring2'.") + "Account-nick 'testalias' mapped to 'testaliasedstring2'.") self.call(general.CmdNick(), "/object testalias = testaliasedstring3", - "Objectnick 'testalias' mapped to 'testaliasedstring3'.") + "Object-nick 'testalias' mapped to 'testaliasedstring3'.") self.assertEqual(u"testaliasedstring1", self.char1.nicks.get("testalias")) self.assertEqual(u"testaliasedstring2", self.char1.nicks.get("testalias", category="account")) self.assertEqual(None, self.char1.account.nicks.get("testalias", category="account")) @@ -214,7 +217,7 @@ class TestSystem(CommandTest): self.call(system.CmdPy(), "1+2", ">>> 1+2|3") def test_scripts(self): - self.call(system.CmdScripts(), "", "| dbref |") + self.call(system.CmdScripts(), "", "dbref ") def test_objects(self): self.call(system.CmdObjects(), "", "Object subtype totals") @@ -238,14 +241,14 @@ class TestAdmin(CommandTest): self.call(admin.CmdWall(), "Test", "Announcing to all connected sessions ...") def test_ban(self): - self.call(admin.CmdBan(), "Char", "NameBan char was added.") + self.call(admin.CmdBan(), "Char", "Name-Ban char was added.") class TestAccount(CommandTest): def test_ooc_look(self): if settings.MULTISESSION_MODE < 2: - self.call(account.CmdOOCLook(), "", "You are outofcharacter (OOC).", caller=self.account) + self.call(account.CmdOOCLook(), "", "You are out-of-character (OOC).", caller=self.account) if settings.MULTISESSION_MODE == 2: self.call(account.CmdOOCLook(), "", "Account TestAccount (you are OutofCharacter)", caller=self.account) @@ -302,8 +305,8 @@ class TestBuilding(CommandTest): def test_attribute_commands(self): self.call(building.CmdSetAttribute(), "Obj/test1=\"value1\"", "Created attribute Obj/test1 = 'value1'") self.call(building.CmdSetAttribute(), "Obj2/test2=\"value2\"", "Created attribute Obj2/test2 = 'value2'") - self.call(building.CmdMvAttr(), "Obj2/test2 = Obj/test3", "Moved Obj2.test2 > Obj.test3") - self.call(building.CmdCpAttr(), "Obj/test1 = Obj2/test3", "Copied Obj.test1 > Obj2.test3") + self.call(building.CmdMvAttr(), "Obj2/test2 = Obj/test3", "Moved Obj2.test2 -> Obj.test3") + self.call(building.CmdCpAttr(), "Obj/test1 = Obj2/test3", "Copied Obj.test1 -> Obj2.test3") self.call(building.CmdWipe(), "Obj2/test2/test3", "Wiped attributes test2,test3 on Obj2.") def test_name(self): @@ -329,7 +332,7 @@ class TestBuilding(CommandTest): def test_exit_commands(self): self.call(building.CmdOpen(), "TestExit1=Room2", "Created new Exit 'TestExit1' from Room to Room2") - self.call(building.CmdLink(), "TestExit1=Room", "Link created TestExit1 > Room (one way).") + self.call(building.CmdLink(), "TestExit1=Room", "Link created TestExit1 -> Room (one way).") self.call(building.CmdUnLink(), "TestExit1", "Former exit TestExit1 no longer links anywhere.") def test_set_home(self): @@ -348,8 +351,8 @@ class TestBuilding(CommandTest): def test_find(self): self.call(building.CmdFind(), "oom2", "One Match") - expect = "One Match(#1#7, loc):\n " +\ - "Char2(#7) evennia.objects.objects.DefaultCharacter (location: Room(#1))" + expect = "One Match(#1-#7, loc):\n " +\ + "Char2(#7) - evennia.objects.objects.DefaultCharacter (location: Room(#1))" self.call(building.CmdFind(), "Char2", expect, cmdstring="locate") self.call(building.CmdFind(), "/ex Char2", # /ex is an ambiguous switch "locate: Ambiguous switch supplied: Did you mean /exit or /exact?|" + expect, @@ -365,7 +368,7 @@ class TestBuilding(CommandTest): def test_teleport(self): self.call(building.CmdTeleport(), "/quiet Room2", "Room2(#2)\n|Teleported to Room2.") self.call(building.CmdTeleport(), "/t", # /t switch is abbreviated form of /tonone - "Cannot teleport a puppeted object (Char, puppeted by TestAccount(account 1)) to a Nonelocation.") + "Cannot teleport a puppeted object (Char, puppeted by TestAccount(account 1)) to a None-location.") self.call(building.CmdTeleport(), "/l Room2", # /l switch is abbreviated form of /loc "Destination has no location.") self.call(building.CmdTeleport(), "/q me to Room2", # /q switch is abbreviated form of /quiet @@ -392,7 +395,7 @@ class TestBuilding(CommandTest): "'typeclass':'evennia.objects.objects.DefaultCharacter'}", "Saved prototype: testprot", inputs=['y']) - self.call(building.CmdSpawn(), "/list", "| Key ") + self.call(building.CmdSpawn(), "/list", "Key ") self.call(building.CmdSpawn(), 'testprot', "Spawned Test Char") # Tests that the spawned object's location is the same as the caharacter's location, since @@ -455,7 +458,7 @@ class TestBuilding(CommandTest): self.call(building.CmdSpawn(), "'NO_EXIST'", "No prototype named 'NO_EXIST'") # Test listing commands - self.call(building.CmdSpawn(), "/list", "| Key ") + self.call(building.CmdSpawn(), "/list", "Key ") class TestComms(CommandTest): @@ -512,7 +515,7 @@ class TestBatchProcess(CommandTest): def test_batch_commands(self): # cannot test batchcode here, it must run inside the server process self.call(batchprocess.CmdBatchCommands(), "example_batch_cmds", - "Running Batchcommand processor Automatic mode for example_batch_cmds") + "Running Batch-command processor - Automatic mode for example_batch_cmds") # we make sure to delete the button again here to stop the running reactor confirm = building.CmdDestroy.confirm building.CmdDestroy.confirm = False @@ -542,5 +545,5 @@ class TestUnconnectedCommand(CommandTest): expected = "## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO" % ( settings.SERVERNAME, datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(), - SESSIONS.account_count(), utils.get_evennia_version().replace("-", "")) + SESSIONS.account_count(), utils.get_evennia_version()) self.call(unloggedin.CmdUnconnectedInfo(), "", expected) diff --git a/evennia/comms/comms.py b/evennia/comms/comms.py index 7ff62dfc27..a7d74ae0e4 100644 --- a/evennia/comms/comms.py +++ b/evennia/comms/comms.py @@ -97,7 +97,8 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)): @property def wholist(self): subs = self.subscriptions.all() - listening = [ob for ob in subs if ob.is_connected and ob not in self.mutelist] + muted = list(self.mutelist) + listening = [ob for ob in subs if ob.is_connected and ob not in muted] if subs: # display listening subscribers in bold string = ", ".join([account.key if account not in listening else "|w%s|n" % account.key for account in subs]) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 60ac50b64d..e3577997cf 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -723,9 +723,9 @@ class TestMapBuilder(CommandTest): "evennia.contrib.mapbuilder.EXAMPLE2_MAP evennia.contrib.mapbuilder.EXAMPLE2_LEGEND", """Creating Map...|≈ ≈ ≈ ≈ ≈ -≈ ♣♣♣ ≈ +≈ ♣-♣-♣ ≈ ≈ ♣ ♣ ♣ ≈ - ≈ ♣♣♣ ≈ + ≈ ♣-♣-♣ ≈ ≈ ≈ ≈ ≈ ≈ |Creating Landmass...|""") @@ -768,8 +768,8 @@ from evennia.contrib import simpledoor class TestSimpleDoor(CommandTest): def test_cmdopen(self): self.call(simpledoor.CmdOpen(), "newdoor;door:contrib.simpledoor.SimpleDoor,backdoor;door = Room2", - "Created new Exit 'newdoor' from Room to Room2 (aliases: door).|Note: A doortype exit was " - "created ignored eventual custom returnexit type.|Created new Exit 'newdoor' from Room2 to Room (aliases: door).") + "Created new Exit 'newdoor' from Room to Room2 (aliases: door).|Note: A door-type exit was " + "created - ignored eventual custom return-exit type.|Created new Exit 'newdoor' from Room2 to Room (aliases: door).") self.call(simpledoor.CmdOpenCloseDoor(), "newdoor", "You close newdoor.", cmdstring="close") self.call(simpledoor.CmdOpenCloseDoor(), "newdoor", "newdoor is already closed.", cmdstring="close") self.call(simpledoor.CmdOpenCloseDoor(), "newdoor", "You open newdoor.", cmdstring="open") @@ -953,13 +953,13 @@ class TestTutorialWorldRooms(CommandTest): # test turnbattle -from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range +from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range, tb_items, tb_magic from evennia.objects.objects import DefaultRoom -class TestTurnBattleCmd(CommandTest): +class TestTurnBattleBasicCmd(CommandTest): - # Test combat commands + # Test basic combat commands def test_turnbattlecmd(self): self.call(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)") @@ -967,13 +967,19 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_basic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_basic.CmdRest(), "", "Char rests to recover HP.") + +class TestTurnBattleEquipCmd(CommandTest): + + def setUp(self): + super(TestTurnBattleEquipCmd, self).setUp() + self.testweapon = create_object(tb_equip.TBEWeapon, key="test weapon") + self.testarmor = create_object(tb_equip.TBEArmor, key="test armor") + self.testweapon.move_to(self.char1) + self.testarmor.move_to(self.char1) + # Test equipment commands def test_turnbattleequipcmd(self): # Start with equip module specific commands. - 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.") @@ -985,6 +991,8 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_equip.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_equip.CmdRest(), "", "Char rests to recover HP.") + +class TestTurnBattleRangeCmd(CommandTest): # Test range commands def test_turnbattlerangecmd(self): # Start with range module specific commands. @@ -999,258 +1007,531 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_range.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_range.CmdRest(), "", "Char rests to recover HP.") +class TestTurnBattleItemsCmd(CommandTest): -class TestTurnBattleFunc(EvenniaTest): + def setUp(self): + super(TestTurnBattleItemsCmd, self).setUp() + self.testitem = create_object(key="test item") + self.testitem.move_to(self.char1) + + # Test item commands + def test_turnbattleitemcmd(self): + self.call(tb_items.CmdUse(), "item", "'Test item' is not a usable item.") + # Also test the commands that are the same in the basic module + self.call(tb_items.CmdFight(), "", "You can't start a fight if you've been defeated!") + self.call(tb_items.CmdAttack(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_items.CmdPass(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_items.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_items.CmdRest(), "", "Char rests to recover HP.") + + +class TestTurnBattleMagicCmd(CommandTest): + + # Test magic commands + def test_turnbattlemagiccmd(self): + self.call(tb_magic.CmdStatus(), "", "You have 100 / 100 HP and 20 / 20 MP.") + self.call(tb_magic.CmdLearnSpell(), "test spell", "There is no spell with that name.") + self.call(tb_magic.CmdCast(), "", "Usage: cast = , ") + # Also test the commands that are the same in the basic module + self.call(tb_magic.CmdFight(), "", "There's nobody here to fight!") + self.call(tb_magic.CmdAttack(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_magic.CmdPass(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_magic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_magic.CmdRest(), "", "Char rests to recover HP and MP.") + + +class TestTurnBattleBasicFunc(EvenniaTest): + + def setUp(self): + super(TestTurnBattleBasicFunc, self).setUp() + self.testroom = create_object(DefaultRoom, key="Test Room") + self.attacker = create_object(tb_basic.TBBasicCharacter, key="Attacker", location=self.testroom) + self.defender = create_object(tb_basic.TBBasicCharacter, key="Defender", location=self.testroom) + self.joiner = create_object(tb_basic.TBBasicCharacter, key="Joiner", location=None) + + def tearDown(self): + super(TestTurnBattleBasicFunc, self).tearDown() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.testroom.delete() + self.turnhandler.stop() # Test combat functions def test_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 = tb_basic.roll_init(attacker) + initiative = tb_basic.roll_init(self.attacker) self.assertTrue(initiative >= 0 and initiative <= 1000) # Attack roll - attack_roll = tb_basic.get_attack(attacker, defender) + attack_roll = tb_basic.get_attack(self.attacker, self.defender) self.assertTrue(attack_roll >= 0 and attack_roll <= 100) # Defense roll - defense_roll = tb_basic.get_defense(attacker, defender) + defense_roll = tb_basic.get_defense(self.attacker, self.defender) self.assertTrue(defense_roll == 50) # Damage roll - damage_roll = tb_basic.get_damage(attacker, defender) + damage_roll = tb_basic.get_damage(self.attacker, self.defender) self.assertTrue(damage_roll >= 15 and damage_roll <= 25) # Apply damage - defender.db.hp = 10 - tb_basic.apply_damage(defender, 3) - self.assertTrue(defender.db.hp == 7) + self.defender.db.hp = 10 + tb_basic.apply_damage(self.defender, 3) + self.assertTrue(self.defender.db.hp == 7) # Resolve attack - defender.db.hp = 40 - tb_basic.resolve_attack(attacker, defender, attack_value=20, defense_value=10) - self.assertTrue(defender.db.hp < 40) + self.defender.db.hp = 40 + tb_basic.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10) + self.assertTrue(self.defender.db.hp < 40) # Combat cleanup - attacker.db.Combat_attribute = True - tb_basic.combat_cleanup(attacker) - self.assertFalse(attacker.db.combat_attribute) + self.attacker.db.Combat_attribute = True + tb_basic.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) # Is in combat - self.assertFalse(tb_basic.is_in_combat(attacker)) + self.assertFalse(tb_basic.is_in_combat(self.attacker)) # Set up turn handler script for further tests - attacker.location.scripts.add(tb_basic.TBBasicTurnHandler) - turnhandler = attacker.db.combat_TurnHandler - self.assertTrue(attacker.db.combat_TurnHandler) + self.attacker.location.scripts.add(tb_basic.TBBasicTurnHandler) + self.turnhandler = self.attacker.db.combat_TurnHandler + self.assertTrue(self.attacker.db.combat_TurnHandler) # Set the turn handler's interval very high to keep it from repeating during tests. - turnhandler.interval = 10000 + self.turnhandler.interval = 10000 # Force turn order - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 # Test is turn - self.assertTrue(tb_basic.is_turn(attacker)) + self.assertTrue(tb_basic.is_turn(self.attacker)) # Spend actions - attacker.db.Combat_ActionsLeft = 1 - tb_basic.spend_action(attacker, 1, action_name="Test") - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "Test") + self.attacker.db.Combat_ActionsLeft = 1 + tb_basic.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") # Initialize for combat - attacker.db.Combat_ActionsLeft = 983 - turnhandler.initialize_for_combat(attacker) - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "null") + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") # Start turn - defender.db.Combat_ActionsLeft = 0 - turnhandler.start_turn(defender) - self.assertTrue(defender.db.Combat_ActionsLeft == 1) + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.defender.db.Combat_ActionsLeft == 1) # Next turn - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.next_turn() - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) # Turn end check - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - attacker.db.Combat_ActionsLeft = 0 - turnhandler.turn_end_check(attacker) - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) # Join fight - joiner = create_object(tb_basic.TBBasicCharacter, key="Joiner") - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.join_fight(joiner) - self.assertTrue(turnhandler.db.turn == 1) - self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) - # Remove the script at the end - turnhandler.stop() + self.joiner.location = self.testroom + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender]) + + +class TestTurnBattleEquipFunc(EvenniaTest): + + def setUp(self): + super(TestTurnBattleEquipFunc, self).setUp() + self.testroom = create_object(DefaultRoom, key="Test Room") + self.attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker", location=self.testroom) + self.defender = create_object(tb_equip.TBEquipCharacter, key="Defender", location=self.testroom) + self.joiner = create_object(tb_equip.TBEquipCharacter, key="Joiner", location=None) + + def tearDown(self): + super(TestTurnBattleEquipFunc, self).tearDown() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.testroom.delete() + self.turnhandler.stop() # Test the combat functions in tb_equip too. They work mostly the same. def test_tbequipfunc(self): - 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) + initiative = tb_equip.roll_init(self.attacker) self.assertTrue(initiative >= 0 and initiative <= 1000) # Attack roll - attack_roll = tb_equip.get_attack(attacker, defender) + attack_roll = tb_equip.get_attack(self.attacker, self.defender) self.assertTrue(attack_roll >= -50 and attack_roll <= 150) # Defense roll - defense_roll = tb_equip.get_defense(attacker, defender) + defense_roll = tb_equip.get_defense(self.attacker, self.defender) self.assertTrue(defense_roll == 50) # Damage roll - damage_roll = tb_equip.get_damage(attacker, defender) + damage_roll = tb_equip.get_damage(self.attacker, self.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) + self.defender.db.hp = 10 + tb_equip.apply_damage(self.defender, 3) + self.assertTrue(self.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) + self.defender.db.hp = 40 + tb_equip.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10) + self.assertTrue(self.defender.db.hp < 40) # Combat cleanup - attacker.db.Combat_attribute = True - tb_equip.combat_cleanup(attacker) - self.assertFalse(attacker.db.combat_attribute) + self.attacker.db.Combat_attribute = True + tb_equip.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) # Is in combat - self.assertFalse(tb_equip.is_in_combat(attacker)) + self.assertFalse(tb_equip.is_in_combat(self.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) + self.attacker.location.scripts.add(tb_equip.TBEquipTurnHandler) + self.turnhandler = self.attacker.db.combat_TurnHandler + self.assertTrue(self.attacker.db.combat_TurnHandler) # Set the turn handler's interval very high to keep it from repeating during tests. - turnhandler.interval = 10000 + self.turnhandler.interval = 10000 # Force turn order - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 # Test is turn - self.assertTrue(tb_equip.is_turn(attacker)) + self.assertTrue(tb_equip.is_turn(self.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") + self.attacker.db.Combat_ActionsLeft = 1 + tb_equip.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") # Initialize for combat - attacker.db.Combat_ActionsLeft = 983 - turnhandler.initialize_for_combat(attacker) - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "null") + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") # Start turn - defender.db.Combat_ActionsLeft = 0 - turnhandler.start_turn(defender) - self.assertTrue(defender.db.Combat_ActionsLeft == 1) + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.defender.db.Combat_ActionsLeft == 1) # Next turn - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.next_turn() - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) # Turn end check - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - attacker.db.Combat_ActionsLeft = 0 - turnhandler.turn_end_check(attacker) - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) # Join fight - joiner = create_object(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() + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender]) + + +class TestTurnBattleRangeFunc(EvenniaTest): + + def setUp(self): + super(TestTurnBattleRangeFunc, self).setUp() + self.testroom = create_object(DefaultRoom, key="Test Room") + self.attacker = create_object(tb_range.TBRangeCharacter, key="Attacker", location=self.testroom) + self.defender = create_object(tb_range.TBRangeCharacter, key="Defender", location=self.testroom) + self.joiner = create_object(tb_range.TBRangeCharacter, key="Joiner", location=self.testroom) + + def tearDown(self): + super(TestTurnBattleRangeFunc, self).tearDown() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.testroom.delete() + self.turnhandler.stop() # Test combat functions in tb_range too. def test_tbrangefunc(self): - 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) + initiative = tb_range.roll_init(self.attacker) self.assertTrue(initiative >= 0 and initiative <= 1000) # Attack roll - attack_roll = tb_range.get_attack(attacker, defender, "test") + attack_roll = tb_range.get_attack(self.attacker, self.defender, "test") self.assertTrue(attack_roll >= 0 and attack_roll <= 100) # Defense roll - defense_roll = tb_range.get_defense(attacker, defender, "test") + defense_roll = tb_range.get_defense(self.attacker, self.defender, "test") self.assertTrue(defense_roll == 50) # Damage roll - damage_roll = tb_range.get_damage(attacker, defender) + damage_roll = tb_range.get_damage(self.attacker, self.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) + self.defender.db.hp = 10 + tb_range.apply_damage(self.defender, 3) + self.assertTrue(self.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) + self.defender.db.hp = 40 + tb_range.resolve_attack(self.attacker, self.defender, "test", attack_value=20, defense_value=10) + self.assertTrue(self.defender.db.hp < 40) # Combat cleanup - attacker.db.Combat_attribute = True - tb_range.combat_cleanup(attacker) - self.assertFalse(attacker.db.combat_attribute) + self.attacker.db.Combat_attribute = True + tb_range.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) # Is in combat - self.assertFalse(tb_range.is_in_combat(attacker)) + self.assertFalse(tb_range.is_in_combat(self.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) + self.attacker.location.scripts.add(tb_range.TBRangeTurnHandler) + self.turnhandler = self.attacker.db.combat_TurnHandler + self.assertTrue(self.attacker.db.combat_TurnHandler) # Set the turn handler's interval very high to keep it from repeating during tests. - turnhandler.interval = 10000 + self.turnhandler.interval = 10000 # Force turn order - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 # Test is turn - self.assertTrue(tb_range.is_turn(attacker)) + self.assertTrue(tb_range.is_turn(self.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") + self.attacker.db.Combat_ActionsLeft = 1 + tb_range.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") # Initialize for combat - attacker.db.Combat_ActionsLeft = 983 - turnhandler.initialize_for_combat(attacker) - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "null") + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") # 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 + self.attacker.db.combat_range = {} + self.attacker.db.combat_range[self.attacker] = 0 + self.attacker.db.combat_range[self.defender] = 1 + self.defender.db.combat_range = {} + self.defender.db.combat_range[self.defender] = 0 + self.defender.db.combat_range[self.attacker] = 1 # Start turn - defender.db.Combat_ActionsLeft = 0 - turnhandler.start_turn(defender) - self.assertTrue(defender.db.Combat_ActionsLeft == 2) + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.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) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) # Turn end check - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - attacker.db.Combat_ActionsLeft = 0 - turnhandler.turn_end_check(attacker) - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) # Join fight - joiner = create_object(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]) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender]) # Now, test for approach/withdraw functions - self.assertTrue(tb_range.get_range(attacker, defender) == 1) + self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 1) # Approach - tb_range.approach(attacker, defender) - self.assertTrue(tb_range.get_range(attacker, defender) == 0) + tb_range.approach(self.attacker, self.defender) + self.assertTrue(tb_range.get_range(self.attacker, self.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() + tb_range.withdraw(self.attacker, self.defender) + self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 1) + + +class TestTurnBattleItemsFunc(EvenniaTest): + + @patch("evennia.contrib.turnbattle.tb_items.tickerhandler", new=MagicMock()) + def setUp(self): + super(TestTurnBattleItemsFunc, self).setUp() + self.testroom = create_object(DefaultRoom, key="Test Room") + self.attacker = create_object(tb_items.TBItemsCharacter, key="Attacker", location=self.testroom) + self.defender = create_object(tb_items.TBItemsCharacter, key="Defender", location=self.testroom) + self.joiner = create_object(tb_items.TBItemsCharacter, key="Joiner", location=self.testroom) + self.user = create_object(tb_items.TBItemsCharacter, key="User", location=self.testroom) + self.test_healpotion = create_object(key="healing potion") + self.test_healpotion.db.item_func = "heal" + self.test_healpotion.db.item_uses = 3 + + def tearDown(self): + super(TestTurnBattleItemsFunc, self).tearDown() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.user.delete() + self.testroom.delete() + self.turnhandler.stop() + + # Test functions in tb_items. + def test_tbitemsfunc(self): + # Initiative roll + initiative = tb_items.roll_init(self.attacker) + self.assertTrue(initiative >= 0 and initiative <= 1000) + # Attack roll + attack_roll = tb_items.get_attack(self.attacker, self.defender) + self.assertTrue(attack_roll >= 0 and attack_roll <= 100) + # Defense roll + defense_roll = tb_items.get_defense(self.attacker, self.defender) + self.assertTrue(defense_roll == 50) + # Damage roll + damage_roll = tb_items.get_damage(self.attacker, self.defender) + self.assertTrue(damage_roll >= 15 and damage_roll <= 25) + # Apply damage + self.defender.db.hp = 10 + tb_items.apply_damage(self.defender, 3) + self.assertTrue(self.defender.db.hp == 7) + # Resolve attack + self.defender.db.hp = 40 + tb_items.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10) + self.assertTrue(self.defender.db.hp < 40) + # Combat cleanup + self.attacker.db.Combat_attribute = True + tb_items.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) + # Is in combat + self.assertFalse(tb_items.is_in_combat(self.attacker)) + # Set up turn handler script for further tests + self.attacker.location.scripts.add(tb_items.TBItemsTurnHandler) + self.turnhandler = self.attacker.db.combat_TurnHandler + self.assertTrue(self.attacker.db.combat_TurnHandler) + # Set the turn handler's interval very high to keep it from repeating during tests. + self.turnhandler.interval = 10000 + # Force turn order + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + # Test is turn + self.assertTrue(tb_items.is_turn(self.attacker)) + # Spend actions + self.attacker.db.Combat_ActionsLeft = 1 + tb_items.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") + # Initialize for combat + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") + # Start turn + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.defender.db.Combat_ActionsLeft == 1) + # Next turn + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) + # Turn end check + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) + # Join fight + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender]) + # Now time to test item stuff. + # Spend item use + tb_items.spend_item_use(self.test_healpotion, self.user) + self.assertTrue(self.test_healpotion.db.item_uses == 2) + # Use item + self.user.db.hp = 2 + tb_items.use_item(self.user, self.test_healpotion, self.user) + self.assertTrue(self.user.db.hp > 2) + # Add contition + tb_items.add_condition(self.user, self.user, "Test", 5) + self.assertTrue(self.user.db.conditions == {"Test":[5, self.user]}) + # Condition tickdown + tb_items.condition_tickdown(self.user, self.user) + self.assertTrue(self.user.db.conditions == {"Test":[4, self.user]}) + # Test item functions now! + # Item heal + self.user.db.hp = 2 + tb_items.itemfunc_heal(self.test_healpotion, self.user, self.user) + # Item add condition + self.user.db.conditions = {} + tb_items.itemfunc_add_condition(self.test_healpotion, self.user, self.user) + self.assertTrue(self.user.db.conditions == {"Regeneration":[5, self.user]}) + # Item cure condition + self.user.db.conditions = {"Poisoned":[5, self.user]} + tb_items.itemfunc_cure_condition(self.test_healpotion, self.user, self.user) + self.assertTrue(self.user.db.conditions == {}) + + +class TestTurnBattleMagicFunc(EvenniaTest): + + def setUp(self): + super(TestTurnBattleMagicFunc, self).setUp() + self.testroom = create_object(DefaultRoom, key="Test Room") + self.attacker = create_object(tb_magic.TBMagicCharacter, key="Attacker", location=self.testroom) + self.defender = create_object(tb_magic.TBMagicCharacter, key="Defender", location=self.testroom) + self.joiner = create_object(tb_magic.TBMagicCharacter, key="Joiner", location=self.testroom) + + def tearDown(self): + super(TestTurnBattleMagicFunc, self).tearDown() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.testroom.delete() + self.turnhandler.stop() + + # Test combat functions in tb_magic. + def test_tbbasicfunc(self): + # Initiative roll + initiative = tb_magic.roll_init(self.attacker) + self.assertTrue(initiative >= 0 and initiative <= 1000) + # Attack roll + attack_roll = tb_magic.get_attack(self.attacker, self.defender) + self.assertTrue(attack_roll >= 0 and attack_roll <= 100) + # Defense roll + defense_roll = tb_magic.get_defense(self.attacker, self.defender) + self.assertTrue(defense_roll == 50) + # Damage roll + damage_roll = tb_magic.get_damage(self.attacker, self.defender) + self.assertTrue(damage_roll >= 15 and damage_roll <= 25) + # Apply damage + self.defender.db.hp = 10 + tb_magic.apply_damage(self.defender, 3) + self.assertTrue(self.defender.db.hp == 7) + # Resolve attack + self.defender.db.hp = 40 + tb_magic.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10) + self.assertTrue(self.defender.db.hp < 40) + # Combat cleanup + self.attacker.db.Combat_attribute = True + tb_magic.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) + # Is in combat + self.assertFalse(tb_magic.is_in_combat(self.attacker)) + # Set up turn handler script for further tests + self.attacker.location.scripts.add(tb_magic.TBMagicTurnHandler) + self.turnhandler = self.attacker.db.combat_TurnHandler + self.assertTrue(self.attacker.db.combat_TurnHandler) + # Set the turn handler's interval very high to keep it from repeating during tests. + self.turnhandler.interval = 10000 + # Force turn order + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + # Test is turn + self.assertTrue(tb_magic.is_turn(self.attacker)) + # Spend actions + self.attacker.db.Combat_ActionsLeft = 1 + tb_magic.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") + # Initialize for combat + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") + # Start turn + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.defender.db.Combat_ActionsLeft == 1) + # Next turn + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) + # Turn end check + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) + # Join fight + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender]) + # Test tree select @@ -1263,6 +1544,7 @@ Bar --Baz 2 -Qux""" + class TestTreeSelectFunc(EvenniaTest): def test_tree_functions(self): diff --git a/evennia/contrib/turnbattle/README.md b/evennia/contrib/turnbattle/README.md index 729c42a099..fd2563bceb 100644 --- a/evennia/contrib/turnbattle/README.md +++ b/evennia/contrib/turnbattle/README.md @@ -21,6 +21,19 @@ implemented and customized: the battle system, including commands for wielding weapons and donning armor, and modifiers to accuracy and damage based on currently used equipment. + + tb_items.py - Adds usable items and conditions/status effects, and gives + a lot of examples for each. Items can perform nearly any sort of + function, including healing, adding or curing conditions, or + being used to attack. Conditions affect a fighter's attributes + and options in combat and persist outside of fights, counting + down per turn in combat and in real time outside combat. + + tb_magic.py - Adds a spellcasting system, allowing characters to cast + spells with a variety of effects by spending MP. Spells are + linked to functions, and as such can perform any sort of action + the developer can imagine - spells for attacking, healing and + conjuring objects are included as examples. tb_range.py - Adds a system for abstract positioning and movement, which tracks the distance between different characters and objects in diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py new file mode 100644 index 0000000000..d0e9fe8e34 --- /dev/null +++ b/evennia/contrib/turnbattle/tb_items.py @@ -0,0 +1,1397 @@ +""" +Simple turn-based combat system with items and status effects + +Contrib - Tim Ashley Jenkins 2017 + +This is a version of the 'turnbattle' combat system that includes +conditions and usable items, which can instill these conditions, cure +them, or do just about anything else. + +Conditions are stored on characters as a dictionary, where the key +is the name of the condition and the value is a list of two items: +an integer representing the number of turns left until the condition +runs out, and the character upon whose turn the condition timer is +ticked down. Unlike most combat-related attributes, conditions aren't +wiped once combat ends - if out of combat, they tick down in real time +instead. + +This module includes a number of example conditions: + + Regeneration: Character recovers HP every turn + Poisoned: Character loses HP every turn + Accuracy Up: +25 to character's attack rolls + Accuracy Down: -25 to character's attack rolls + Damage Up: +5 to character's damage + Damage Down: -5 to character's damage + Defense Up: +15 to character's defense + Defense Down: -15 to character's defense + Haste: +1 action per turn + Paralyzed: No actions per turn + Frightened: Character can't use the 'attack' command + +Since conditions can have a wide variety of effects, their code is +scattered throughout the other functions wherever they may apply. + +Items aren't given any sort of special typeclass - instead, whether or +not an object counts as an item is determined by its attributes. To make +an object into an item, it must have the attribute 'item_func', with +the value given as a callable - this is the function that will be called +when an item is used. Other properties of the item, such as how many +uses it has, whether it's destroyed when its uses are depleted, and such +can be specified on the item as well, but they are optional. + +To install and test, import this module's TBItemsCharacter object into +your game's character.py module: + + from evennia.contrib.turnbattle.tb_items import TBItemsCharacter + +And change your game's character typeclass to inherit from TBItemsCharacter +instead of the default: + + class Character(TBItemsCharacter): + +Next, import this module into your default_cmdsets.py module: + + from evennia.contrib.turnbattle import tb_items + +And add the battle command set to your default command set: + + # + # any commands you add below will overload the default ones. + # + self.add(tb_items.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 +from evennia.commands.default.muxcommand import MuxCommand +from evennia.commands.default.help import CmdHelp +from evennia.utils.spawner import spawn +from evennia import TICKER_HANDLER as tickerhandler + +""" +---------------------------------------------------------------------------- +OPTIONS +---------------------------------------------------------------------------- +""" + +TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds +ACTIONS_PER_TURN = 1 # Number of actions allowed per turn +NONCOMBAT_TURN_TIME = 30 # Time per turn count out of combat + +# Condition options start here. +# If you need to make changes to how your conditions work later, +# it's best to put the easily tweakable values all in one place! + +REGEN_RATE = (4, 8) # Min and max HP regen for Regeneration +POISON_RATE = (4, 8) # Min and max damage for Poisoned +ACC_UP_MOD = 25 # Accuracy Up attack roll bonus +ACC_DOWN_MOD = -25 # Accuracy Down attack roll penalty +DMG_UP_MOD = 5 # Damage Up damage roll bonus +DMG_DOWN_MOD = -5 # Damage Down damage roll penalty +DEF_UP_MOD = 15 # Defense Up defense bonus +DEF_DOWN_MOD = -15 # Defense Down defense penalty + +""" +---------------------------------------------------------------------------- +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: + This is where conditions affecting attack rolls are applied, as well. + Accuracy Up and Accuracy Down are also accounted for in itemfunc_attack(), + so that attack items' accuracy is affected as well. + """ + # For this example, just return a random integer up to 100. + attack_value = randint(1, 100) + # Add to the roll if the attacker has the "Accuracy Up" condition. + if "Accuracy Up" in attacker.db.conditions: + attack_value += ACC_UP_MOD + # Subtract from the roll if the attack has the "Accuracy Down" condition. + if "Accuracy Down" in attacker.db.conditions: + attack_value += ACC_DOWN_MOD + 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: + This is where conditions affecting defense are accounted for. + """ + # For this example, just return 50, for about a 50/50 chance of hit. + defense_value = 50 + # Add to defense if the defender has the "Defense Up" condition. + if "Defense Up" in defender.db.conditions: + defense_value += DEF_UP_MOD + # Subtract from defense if the defender has the "Defense Down" condition. + if "Defense Down" in defender.db.conditions: + defense_value += DEF_DOWN_MOD + 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: + This is where conditions affecting damage are accounted for. Since attack items + roll their own damage in itemfunc_attack(), their damage is unaffected by any + conditions. + """ + # For this example, just generate a number between 15 and 25. + damage_value = randint(15, 25) + # Add to damage roll if attacker has the "Damage Up" condition. + if "Damage Up" in attacker.db.conditions: + damage_value += DMG_UP_MOD + # Subtract from the roll if the attacker has the "Damage Down" condition. + if "Damage Down" in attacker.db.conditions: + damage_value += DMG_DOWN_MOD + 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, + damage_value=None, inflict_condition=[]): + """ + Resolves an attack and outputs the result. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Options: + attack_value (int): Override for attack roll + defense_value (int): Override for defense value + damage_value (int): Override for damage value + inflict_condition (list): Conditions to inflict upon hit, a + list of tuples formated as (condition(str), duration(int)) + + Notes: + This function is called by normal attacks as well as attacks + made with items. + """ + # 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 attack misses %s!" % (attacker, defender)) + else: + if not damage_value: + damage_value = get_damage(attacker, defender) # Calculate damage value. + # 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) + # Inflict conditions on hit, if any specified + for condition in inflict_condition: + add_condition(defender, attacker, condition[0], condition[1]) + # 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. + +def spend_item_use(item, user): + """ + Spends one use on an item with limited uses. + + Args: + item (obj): Item being used + user (obj): Character using the item + + Notes: + If item.db.item_consumable is 'True', the item is destroyed if it + runs out of uses - if it's a string instead of 'True', it will also + spawn a new object as residue, using the value of item.db.item_consumable + as the name of the prototype to spawn. + """ + item.db.item_uses -= 1 # Spend one use + + if item.db.item_uses > 0: # Has uses remaining + # Inform the player + user.msg("%s has %i uses remaining." % (item.key.capitalize(), item.db.item_uses)) + + else: # All uses spent + + if not item.db.item_consumable: # Item isn't consumable + # Just inform the player that the uses are gone + user.msg("%s has no uses remaining." % item.key.capitalize()) + + else: # If item is consumable + if item.db.item_consumable == True: # If the value is 'True', just destroy the item + user.msg("%s has been consumed." % item.key.capitalize()) + item.delete() # Delete the spent item + + else: # If a string, use value of item_consumable to spawn an object in its place + residue = spawn({"prototype":item.db.item_consumable})[0] # Spawn the residue + residue.location = item.location # Move the residue to the same place as the item + user.msg("After using %s, you are left with %s." % (item, residue)) + item.delete() # Delete the spent item + +def use_item(user, item, target): + """ + Performs the action of using an item. + + Args: + user (obj): Character using the item + item (obj): Item being used + target (obj): Target of the item use + """ + # If item is self only and no target given, set target to self. + if item.db.item_selfonly and target == None: + target = user + + # If item is self only, abort use if used on others. + if item.db.item_selfonly and user != target: + user.msg("%s can only be used on yourself." % item) + return + + # Set kwargs to pass to item_func + kwargs = {} + if item.db.item_kwargs: + kwargs = item.db.item_kwargs + + # Match item_func string to function + try: + item_func = ITEMFUNCS[item.db.item_func] + except KeyError: # If item_func string doesn't match to a function in ITEMFUNCS + user.msg("ERROR: %s not defined in ITEMFUNCS" % item.db.item_func) + return + + # Call the item function - abort if it returns False, indicating an error. + # This performs the actual action of using the item. + # Regardless of what the function returns (if anything), it's still executed. + if item_func(item, user, target, **kwargs) == False: + return + + # If we haven't returned yet, we assume the item was used successfully. + # Spend one use if item has limited uses + if item.db.item_uses: + spend_item_use(item, user) + + # Spend an action if in combat + if is_in_combat(user): + spend_action(user, 1, action_name="item") + +def condition_tickdown(character, turnchar): + """ + Ticks down the duration of conditions on a character at the start of a given character's turn. + + Args: + character (obj): Character to tick down the conditions of + turnchar (obj): Character whose turn it currently is + + Notes: + In combat, this is called on every fighter at the start of every character's turn. Out of + combat, it's instead called when a character's at_update() hook is called, which is every + 30 seconds by default. + """ + + for key in character.db.conditions: + # The first value is the remaining turns - the second value is whose turn to count down on. + condition_duration = character.db.conditions[key][0] + condition_turnchar = character.db.conditions[key][1] + # If the duration is 'True', then the condition doesn't tick down - it lasts indefinitely. + if not condition_duration is True: + # Count down if the given turn character matches the condition's turn character. + if condition_turnchar == turnchar: + character.db.conditions[key][0] -= 1 + if character.db.conditions[key][0] <= 0: + # If the duration is brought down to 0, remove the condition and inform everyone. + character.location.msg_contents("%s no longer has the '%s' condition." % (str(character), str(key))) + del character.db.conditions[key] + +def add_condition(character, turnchar, condition, duration): + """ + Adds a condition to a fighter. + + Args: + character (obj): Character to give the condition to + turnchar (obj): Character whose turn to tick down the condition on in combat + condition (str): Name of the condition + duration (int or True): Number of turns the condition lasts, or True for indefinite + """ + # The first value is the remaining turns - the second value is whose turn to count down on. + character.db.conditions.update({condition:[duration, turnchar]}) + # Tell everyone! + character.location.msg_contents("%s gains the '%s' condition." % (character, condition)) + +""" +---------------------------------------------------------------------------- +CHARACTER TYPECLASS +---------------------------------------------------------------------------- +""" + + +class TBItemsCharacter(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.conditions = {} # Set empty dict for conditions + # Subscribe character to the ticker handler + tickerhandler.add(NONCOMBAT_TURN_TIME, self.at_update, idstring="update") + """ + Adds attributes for a character's current and maximum HP. + We're just going to set this value at '100' by default. + + An empty dictionary is created to store conditions later, + and the character is subscribed to the Ticker Handler, which + will call at_update() on the character, with the interval + specified by NONCOMBAT_TURN_TIME above. This is used to tick + down conditions out of combat. + + 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 + + def at_turn_start(self): + """ + Hook called at the beginning of this character's turn in combat. + """ + # Prompt the character for their turn and give some information. + self.msg("|wIt's your turn! You have %i HP remaining.|n" % self.db.hp) + + # Apply conditions that fire at the start of each turn. + self.apply_turn_conditions() + + def apply_turn_conditions(self): + """ + Applies the effect of conditions that occur at the start of each + turn in combat, or every 30 seconds out of combat. + """ + # Regeneration: restores 4 to 8 HP at the start of character's turn + if "Regeneration" in self.db.conditions: + to_heal = randint(REGEN_RATE[0], REGEN_RAGE[1]) # Restore HP + if self.db.hp + to_heal > self.db.max_hp: + to_heal = self.db.max_hp - self.db.hp # Cap healing to max HP + self.db.hp += to_heal + self.location.msg_contents("%s regains %i HP from Regeneration." % (self, to_heal)) + + # Poisoned: does 4 to 8 damage at the start of character's turn + if "Poisoned" in self.db.conditions: + to_hurt = randint(POISON_RATE[0], POISON_RATE[1]) # Deal damage + apply_damage(self, to_hurt) + self.location.msg_contents("%s takes %i damage from being Poisoned." % (self, to_hurt)) + if self.db.hp <= 0: + # Call at_defeat if poison defeats the character + at_defeat(self) + + # Haste: Gain an extra action in combat. + if is_in_combat(self) and "Haste" in self.db.conditions: + self.db.combat_actionsleft += 1 + self.msg("You gain an extra action this turn from Haste!") + + # Paralyzed: Have no actions in combat. + if is_in_combat(self) and "Paralyzed" in self.db.conditions: + self.db.combat_actionsleft = 0 + self.location.msg_contents("%s is Paralyzed, and can't act this turn!" % self) + self.db.combat_turnhandler.turn_end_check(self) + + def at_update(self): + """ + Fires every 30 seconds. + """ + if not is_in_combat(self): # Not in combat + # Change all conditions to update on character's turn. + for key in self.db.conditions: + self.db.conditions[key][1] = self + # Apply conditions that fire every turn + self.apply_turn_conditions() + # Tick down condition durations + condition_tickdown(self, self) + +class TBItemsCharacterTest(TBItemsCharacter): + """ + Just like the TBItemsCharacter, but doesn't subscribe to the TickerHandler. + This makes it easier to run unit tests on. + """ + def at_object_creation(self): + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.conditions = {} # Set empty dict for conditions + + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +class TBItemsTurnHandler(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 + # Call character's at_turn_start() hook. + character.at_turn_start() + + 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. + + # Count down condition timers. + for fighter in self.db.fighters: + condition_tickdown(fighter, newchar) + + 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 +---------------------------------------------------------------------------- +""" + + +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_items.TBItemsTurnHandler") + # 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 + + if "Frightened" in self.caller.db.conditions: # Can't attack if frightened + self.caller.msg("You're too frightened to attack!") + 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.|/" + + "|wUse:|n Use an item you're carrying.") + else: + super(CmdCombatHelp, self).func() # Call the default help command + + +class CmdUse(MuxCommand): + """ + Use an item. + + Usage: + use [= target] + + An item can have various function - looking at the item may + provide information as to its effects. Some items can be used + to attack others, and as such can only be used in combat. + """ + + key = "use" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + # Search for item + item = self.caller.search(self.lhs, candidates=self.caller.contents) + if not item: + return + + # Search for target, if any is given + target = None + if self.rhs: + target = self.caller.search(self.rhs) + if not target: + return + + # If in combat, can only use items on your turn + if is_in_combat(self.caller): + if not is_turn(self.caller): + self.caller.msg("You can only use items on your turn.") + return + + if not item.db.item_func: # Object has no item_func, not usable + self.caller.msg("'%s' is not a usable item." % item.key.capitalize()) + return + + if item.attributes.has("item_uses"): # Item has limited uses + if item.db.item_uses <= 0: # Limited uses are spent + self.caller.msg("'%s' has no uses remaining." % item.key.capitalize()) + return + + # If everything checks out, call the use_item function + use_item(self.caller, item, target) + + +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(CmdUse()) + +""" +---------------------------------------------------------------------------- +ITEM FUNCTIONS START HERE +---------------------------------------------------------------------------- + +These functions carry out the action of using an item - every item should +contain a db entry "item_func", with its value being a string that is +matched to one of these functions in the ITEMFUNCS dictionary below. + +Every item function must take the following arguments: + item (obj): The item being used + user (obj): The character using the item + target (obj): The target of the item use + +Item functions must also accept **kwargs - these keyword arguments can be +used to define how different items that use the same function can have +different effects (for example, different attack items doing different +amounts of damage). + +Each function below contains a description of what kwargs the function will +take and the effect they have on the result. +""" + +def itemfunc_heal(item, user, target, **kwargs): + """ + Item function that heals HP. + + kwargs: + min_healing(int): Minimum amount of HP recovered + max_healing(int): Maximum amount of HP recovered + """ + if not target: + target = user # Target user if none specified + + if not target.attributes.has("max_hp"): # Has no HP to speak of + user.msg("You can't use %s on that." % item) + return False # Returning false aborts the item use + + if target.db.hp >= target.db.max_hp: + user.msg("%s is already at full health." % target) + return False + + min_healing = 20 + max_healing = 40 + + # Retrieve healing range from kwargs, if present + if "healing_range" in kwargs: + min_healing = kwargs["healing_range"][0] + max_healing = kwargs["healing_range"][1] + + to_heal = randint(min_healing, max_healing) # Restore 20 to 40 hp + if target.db.hp + to_heal > target.db.max_hp: + to_heal = target.db.max_hp - target.db.hp # Cap healing to max HP + target.db.hp += to_heal + + user.location.msg_contents("%s uses %s! %s regains %i HP!" % (user, item, target, to_heal)) + +def itemfunc_add_condition(item, user, target, **kwargs): + """ + Item function that gives the target one or more conditions. + + kwargs: + conditions (list): Conditions added by the item + formatted as a list of tuples: (condition (str), duration (int or True)) + + Notes: + Should mostly be used for beneficial conditions - use itemfunc_attack + for an item that can give an enemy a harmful condition. + """ + conditions = [("Regeneration", 5)] + + if not target: + target = user # Target user if none specified + + if not target.attributes.has("max_hp"): # Is not a fighter + user.msg("You can't use %s on that." % item) + return False # Returning false aborts the item use + + # Retrieve condition / duration from kwargs, if present + if "conditions" in kwargs: + conditions = kwargs["conditions"] + + user.location.msg_contents("%s uses %s!" % (user, item)) + + # Add conditions to the target + for condition in conditions: + add_condition(target, user, condition[0], condition[1]) + +def itemfunc_cure_condition(item, user, target, **kwargs): + """ + Item function that'll remove given conditions from a target. + + kwargs: + to_cure(list): List of conditions (str) that the item cures when used + """ + to_cure = ["Poisoned"] + + if not target: + target = user # Target user if none specified + + if not target.attributes.has("max_hp"): # Is not a fighter + user.msg("You can't use %s on that." % item) + return False # Returning false aborts the item use + + # Retrieve condition(s) to cure from kwargs, if present + if "to_cure" in kwargs: + to_cure = kwargs["to_cure"] + + item_msg = "%s uses %s! " % (user, item) + + for key in target.db.conditions: + if key in to_cure: + # If condition specified in to_cure, remove it. + item_msg += "%s no longer has the '%s' condition. " % (str(target), str(key)) + del target.db.conditions[key] + + user.location.msg_contents(item_msg) + +def itemfunc_attack(item, user, target, **kwargs): + """ + Item function that attacks a target. + + kwargs: + min_damage(int): Minimum damage dealt by the attack + max_damage(int): Maximum damage dealth by the attack + accuracy(int): Bonus / penalty to attack accuracy roll + inflict_condition(list): List of conditions inflicted on hit, + formatted as a (str, int) tuple containing condition name + and duration. + + Notes: + Calls resolve_attack at the end. + """ + if not is_in_combat(user): + user.msg("You can only use that in combat.") + return False # Returning false aborts the item use + + if not target: + user.msg("You have to specify a target to use %s! (use = )" % item) + return False + + if target == user: + user.msg("You can't attack yourself!") + return False + + if not target.db.hp: # Has no HP + user.msg("You can't use %s on that." % item) + return False + + min_damage = 20 + max_damage = 40 + accuracy = 0 + inflict_condition = [] + + # Retrieve values from kwargs, if present + if "damage_range" in kwargs: + min_damage = kwargs["damage_range"][0] + max_damage = kwargs["damage_range"][1] + if "accuracy" in kwargs: + accuracy = kwargs["accuracy"] + if "inflict_condition" in kwargs: + inflict_condition = kwargs["inflict_condition"] + + # Roll attack and damage + attack_value = randint(1, 100) + accuracy + damage_value = randint(min_damage, max_damage) + + # Account for "Accuracy Up" and "Accuracy Down" conditions + if "Accuracy Up" in user.db.conditions: + attack_value += 25 + if "Accuracy Down" in user.db.conditions: + attack_value -= 25 + + user.location.msg_contents("%s attacks %s with %s!" % (user, target, item)) + resolve_attack(user, target, attack_value=attack_value, + damage_value=damage_value, inflict_condition=inflict_condition) + +# Match strings to item functions here. We can't store callables on +# prototypes, so we store a string instead, matching that string to +# a callable in this dictionary. +ITEMFUNCS = { + "heal":itemfunc_heal, + "attack":itemfunc_attack, + "add_condition":itemfunc_add_condition, + "cure_condition":itemfunc_cure_condition +} + +""" +---------------------------------------------------------------------------- +PROTOTYPES START HERE +---------------------------------------------------------------------------- + +You can paste these prototypes into your game's prototypes.py module in your +/world/ folder, and use the spawner to create them - they serve as examples +of items you can make and a handy way to demonstrate the system for +conditions as well. + +Items don't have any particular typeclass - any object with a db entry +"item_func" that references one of the functions given above can be used as +an item with the 'use' command. + +Only "item_func" is required, but item behavior can be further modified by +specifying any of the following: + + item_uses (int): If defined, item has a limited number of uses + + item_selfonly (bool): If True, user can only use the item on themself + + item_consumable(True or str): If True, item is destroyed when it runs + out of uses. If a string is given, the item will spawn a new + object as it's destroyed, with the string specifying what prototype + to spawn. + + item_kwargs (dict): Keyword arguments to pass to the function defined in + item_func. Unique to each function, and can be used to make multiple + items using the same function work differently. +""" + +MEDKIT = { + "key" : "a medical kit", + "aliases" : ["medkit"], + "desc" : "A standard medical kit. It can be used a few times to heal wounds.", + "item_func" : "heal", + "item_uses" : 3, + "item_consumable" : True, + "item_kwargs" : {"healing_range":(15, 25)} +} + +GLASS_BOTTLE = { + "key" : "a glass bottle", + "desc" : "An empty glass bottle." +} + +HEALTH_POTION = { + "key" : "a health potion", + "desc" : "A glass bottle full of a mystical potion that heals wounds when used.", + "item_func" : "heal", + "item_uses" : 1, + "item_consumable" : "GLASS_BOTTLE", + "item_kwargs" : {"healing_range":(35, 50)} +} + +REGEN_POTION = { + "key" : "a regeneration potion", + "desc" : "A glass bottle full of a mystical potion that regenerates wounds over time.", + "item_func" : "add_condition", + "item_uses" : 1, + "item_consumable" : "GLASS_BOTTLE", + "item_kwargs" : {"conditions":[("Regeneration", 10)]} +} + +HASTE_POTION = { + "key" : "a haste potion", + "desc" : "A glass bottle full of a mystical potion that hastens its user.", + "item_func" : "add_condition", + "item_uses" : 1, + "item_consumable" : "GLASS_BOTTLE", + "item_kwargs" : {"conditions":[("Haste", 10)]} +} + +BOMB = { + "key" : "a rotund bomb", + "desc" : "A large black sphere with a fuse at the end. Can be used on enemies in combat.", + "item_func" : "attack", + "item_uses" : 1, + "item_consumable" : True, + "item_kwargs" : {"damage_range":(25, 40), "accuracy":25} +} + +POISON_DART = { + "key" : "a poison dart", + "desc" : "A thin dart coated in deadly poison. Can be used on enemies in combat", + "item_func" : "attack", + "item_uses" : 1, + "item_consumable" : True, + "item_kwargs" : {"damage_range":(5, 10), "accuracy":25, "inflict_condition":[("Poisoned", 10)]} +} + +TASER = { + "key" : "a taser", + "desc" : "A device that can be used to paralyze enemies in combat.", + "item_func" : "attack", + "item_kwargs" : {"damage_range":(10, 20), "accuracy":0, "inflict_condition":[("Paralyzed", 1)]} +} + +GHOST_GUN = { + "key" : "a ghost gun", + "desc" : "A gun that fires scary ghosts at people. Anyone hit by a ghost becomes frightened.", + "item_func" : "attack", + "item_uses" : 6, + "item_kwargs" : {"damage_range":(5, 10), "accuracy":15, "inflict_condition":[("Frightened", 1)]} +} + +ANTIDOTE_POTION = { + "key" : "an antidote potion", + "desc" : "A glass bottle full of a mystical potion that cures poison when used.", + "item_func" : "cure_condition", + "item_uses" : 1, + "item_consumable" : "GLASS_BOTTLE", + "item_kwargs" : {"to_cure":["Poisoned"]} +} + +AMULET_OF_MIGHT = { + "key" : "The Amulet of Might", + "desc" : "The one who holds this amulet can call upon its power to gain great strength.", + "item_func" : "add_condition", + "item_selfonly" : True, + "item_kwargs" : {"conditions":[("Damage Up", 3), ("Accuracy Up", 3), ("Defense Up", 3)]} +} + +AMULET_OF_WEAKNESS = { + "key" : "The Amulet of Weakness", + "desc" : "The one who holds this amulet can call upon its power to gain great weakness. It's not a terribly useful artifact.", + "item_func" : "add_condition", + "item_selfonly" : True, + "item_kwargs" : {"conditions":[("Damage Down", 3), ("Accuracy Down", 3), ("Defense Down", 3)]} +} diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py new file mode 100644 index 0000000000..7bf87d70be --- /dev/null +++ b/evennia/contrib/turnbattle/tb_magic.py @@ -0,0 +1,1290 @@ +""" +Simple turn-based combat system with spell casting + +Contrib - Tim Ashley Jenkins 2017 + +This is a version of the 'turnbattle' contrib that includes a basic, +expandable framework for a 'magic system', whereby players can spend +a limited resource (MP) to achieve a wide variety of effects, both in +and out of combat. This does not have to strictly be a system for +magic - it can easily be re-flavored to any other sort of resource +based mechanic, like psionic powers, special moves and stamina, and +so forth. + +In this system, spells are learned by name with the 'learnspell' +command, and then used with the 'cast' command. Spells can be cast in or +out of combat - some spells can only be cast in combat, some can only be +cast outside of combat, and some can be cast any time. However, if you +are in combat, you can only cast a spell on your turn, and doing so will +typically use an action (as specified in the spell's funciton). + +Spells are defined at the end of the module in a database that's a +dictionary of dictionaries - each spell is matched by name to a function, +along with various parameters that restrict when the spell can be used and +what the spell can be cast on. Included is a small variety of spells that +damage opponents and heal HP, as well as one that creates an object. + +Because a spell can call any function, a spell can be made to do just +about anything at all. The SPELLS dictionary at the bottom of the module +even allows kwargs to be passed to the spell function, so that the same +function can be re-used for multiple similar spells. + +Spells in this system work on a very basic resource: MP, which is spent +when casting spells and restored by resting. It shouldn't be too difficult +to modify this system to use spell slots, some physical fuel or resource, +or whatever else your game requires. + +To install and test, import this module's TBMagicCharacter object into +your game's character.py module: + + from evennia.contrib.turnbattle.tb_magic import TBMagicCharacter + +And change your game's character typeclass to inherit from TBMagicCharacter +instead of the default: + + class Character(TBMagicCharacter): + +Next, import this module into your default_cmdsets.py module: + + from evennia.contrib.turnbattle import tb_magic + +And add the battle command set to your default command set: + + # + # any commands you add below will overload the default ones. + # + self.add(tb_magic.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, create_object +from evennia.commands.default.muxcommand import MuxCommand +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: + By default, returns a random integer from 1 to 100 without using any + properties from either the attacker or defender. + + 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) + 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: + 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_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 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 attack misses %s!" % (attacker, defender)) + else: + damage_value = get_damage(attacker, defender) # Calculate damage value. + # 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, 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 not is_in_combat(character): + return + 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. + + +""" +---------------------------------------------------------------------------- +CHARACTER TYPECLASS +---------------------------------------------------------------------------- +""" + + +class TBMagicCharacter(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. + + 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. + """ + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.spells_known = [] # Set empty spells known list + self.db.max_mp = 20 # Set maximum MP to 20 + self.db.mp = self.db.max_mp # Set current MP to maximum + + + 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 + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +class TBMagicTurnHandler(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 +---------------------------------------------------------------------------- +""" + + +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_magic.TBMagicTurnHandler") + # 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 CmdLearnSpell(Command): + """ + Learn a magic spell. + + Usage: + learnspell + + Adds a spell by name to your list of spells known. + + The following spells are provided as examples: + + |wmagic missile|n (3 MP): Fires three missiles that never miss. Can target + up to three different enemies. + + |wflame shot|n (3 MP): Shoots a high-damage jet of flame at one target. + + |wcure wounds|n (5 MP): Heals damage on one target. + + |wmass cure wounds|n (10 MP): Like 'cure wounds', but can heal up to 5 + targets at once. + + |wfull heal|n (12 MP): Heals one target back to full HP. + + |wcactus conjuration|n (2 MP): Creates a cactus. + """ + + key = "learnspell" + help_category = "magic" + + def func(self): + """ + This performs the actual command. + """ + spell_list = sorted(SPELLS.keys()) + args = self.args.lower() + args = args.strip(" ") + caller = self.caller + spell_to_learn = [] + + if not args or len(args) < 3: # No spell given + caller.msg("Usage: learnspell ") + return + + for spell in spell_list: # Match inputs to spells + if args in spell.lower(): + spell_to_learn.append(spell) + + if spell_to_learn == []: # No spells matched + caller.msg("There is no spell with that name.") + return + if len(spell_to_learn) > 1: # More than one match + matched_spells = ', '.join(spell_to_learn) + caller.msg("Which spell do you mean: %s?" % matched_spells) + return + + if len(spell_to_learn) == 1: # If one match, extract the string + spell_to_learn = spell_to_learn[0] + + if spell_to_learn not in self.caller.db.spells_known: # If the spell isn't known... + caller.db.spells_known.append(spell_to_learn) # ...then add the spell to the character + caller.msg("You learn the spell '%s'!" % spell_to_learn) + return + if spell_to_learn in self.caller.db.spells_known: # Already has the spell specified + caller.msg("You already know the spell '%s'!" % spell_to_learn) + """ + You will almost definitely want to replace this with your own system + for learning spells, perhaps tied to character advancement or finding + items in the game world that spells can be learned from. + """ + +class CmdCast(MuxCommand): + """ + Cast a magic spell that you know, provided you have the MP + to spend on its casting. + + Usage: + cast [= , , etc...] + + Some spells can be cast on multiple targets, some can be cast + on only yourself, and some don't need a target specified at all. + Typing 'cast' by itself will give you a list of spells you know. + """ + + key = "cast" + help_category = "magic" + + def func(self): + """ + This performs the actual command. + + Note: This is a quite long command, since it has to cope with all + the different circumstances in which you may or may not be able + to cast a spell. None of the spell's effects are handled by the + command - all the command does is verify that the player's input + is valid for the spell being cast and then call the spell's + function. + """ + caller = self.caller + + if not self.lhs or len(self.lhs) < 3: # No spell name given + caller.msg("Usage: cast = , , ...") + if not caller.db.spells_known: + caller.msg("You don't know any spells.") + return + else: + caller.db.spells_known = sorted(caller.db.spells_known) + spells_known_msg = "You know the following spells:|/" + "|/".join(caller.db.spells_known) + caller.msg(spells_known_msg) # List the spells the player knows + return + + spellname = self.lhs.lower() + spell_to_cast = [] + spell_targets = [] + + if not self.rhs: + spell_targets = [] + elif self.rhs.lower() in ['me', 'self', 'myself']: + spell_targets = [caller] + elif len(self.rhs) > 2: + spell_targets = self.rhslist + + for spell in caller.db.spells_known: # Match inputs to spells + if self.lhs in spell.lower(): + spell_to_cast.append(spell) + + if spell_to_cast == []: # No spells matched + caller.msg("You don't know a spell of that name.") + return + if len(spell_to_cast) > 1: # More than one match + matched_spells = ', '.join(spell_to_cast) + caller.msg("Which spell do you mean: %s?" % matched_spells) + return + + if len(spell_to_cast) == 1: # If one match, extract the string + spell_to_cast = spell_to_cast[0] + + if spell_to_cast not in SPELLS: # Spell isn't defined + caller.msg("ERROR: Spell %s is undefined" % spell_to_cast) + return + + # Time to extract some info from the chosen spell! + spelldata = SPELLS[spell_to_cast] + + # Add in some default data if optional parameters aren't specified + if "combat_spell" not in spelldata: + spelldata.update({"combat_spell":True}) + if "noncombat_spell" not in spelldata: + spelldata.update({"noncombat_spell":True}) + if "max_targets" not in spelldata: + spelldata.update({"max_targets":1}) + + # Store any superfluous options as kwargs to pass to the spell function + kwargs = {} + spelldata_opts = ["spellfunc", "target", "cost", "combat_spell", "noncombat_spell", "max_targets"] + for key in spelldata: + if key not in spelldata_opts: + kwargs.update({key:spelldata[key]}) + + # If caster doesn't have enough MP to cover the spell's cost, give error and return + if spelldata["cost"] > caller.db.mp: + caller.msg("You don't have enough MP to cast '%s'." % spell_to_cast) + return + + # If in combat and the spell isn't a combat spell, give error message and return + if spelldata["combat_spell"] == False and is_in_combat(caller): + caller.msg("You can't use the spell '%s' in combat." % spell_to_cast) + return + + # If not in combat and the spell isn't a non-combat spell, error ms and return. + if spelldata["noncombat_spell"] == False and is_in_combat(caller) == False: + caller.msg("You can't use the spell '%s' outside of combat." % spell_to_cast) + return + + # If spell takes no targets and one is given, give error message and return + if len(spell_targets) > 0 and spelldata["target"] == "none": + caller.msg("The spell '%s' isn't cast on a target." % spell_to_cast) + return + + # If no target is given and spell requires a target, give error message + if spelldata["target"] not in ["self", "none"]: + if len(spell_targets) == 0: + caller.msg("The spell '%s' requires a target." % spell_to_cast) + return + + # If more targets given than maximum, give error message + if len(spell_targets) > spelldata["max_targets"]: + targplural = "target" + if spelldata["max_targets"] > 1: + targplural = "targets" + caller.msg("The spell '%s' can only be cast on %i %s." % (spell_to_cast, spelldata["max_targets"], targplural)) + return + + # Set up our candidates for targets + target_candidates = [] + + # If spell targets 'any' or 'other', any object in caster's inventory or location + # can be targeted by the spell. + if spelldata["target"] in ["any", "other"]: + target_candidates = caller.location.contents + caller.contents + + # If spell targets 'anyobj', only non-character objects can be targeted. + if spelldata["target"] == "anyobj": + prefilter_candidates = caller.location.contents + caller.contents + for thing in prefilter_candidates: + if not thing.attributes.has("max_hp"): # Has no max HP, isn't a fighter + target_candidates.append(thing) + + # If spell targets 'anychar' or 'otherchar', only characters can be targeted. + if spelldata["target"] in ["anychar", "otherchar"]: + prefilter_candidates = caller.location.contents + for thing in prefilter_candidates: + if thing.attributes.has("max_hp"): # Has max HP, is a fighter + target_candidates.append(thing) + + # Now, match each entry in spell_targets to an object in the search candidates + matched_targets = [] + for target in spell_targets: + match = caller.search(target, candidates=target_candidates) + matched_targets.append(match) + spell_targets = matched_targets + + # If no target is given and the spell's target is 'self', set target to self + if len(spell_targets) == 0 and spelldata["target"] == "self": + spell_targets = [caller] + + # Give error message if trying to cast an "other" target spell on yourself + if spelldata["target"] in ["other", "otherchar"]: + if caller in spell_targets: + caller.msg("You can't cast '%s' on yourself." % spell_to_cast) + return + + # Return if "None" in target list, indicating failed match + if None in spell_targets: + # No need to give an error message, as 'search' gives one by default. + return + + # Give error message if repeats in target list + if len(spell_targets) != len(set(spell_targets)): + caller.msg("You can't specify the same target more than once!") + return + + # Finally, we can cast the spell itself. Note that MP is not deducted here! + try: + spelldata["spellfunc"](caller, spell_to_cast, spell_targets, spelldata["cost"], **kwargs) + except Exception: + log_trace("Error in callback for spell: %s." % spell_to_cast) + + +class CmdRest(Command): + """ + Recovers damage and restores MP. + + Usage: + rest + + Resting recovers your HP and MP to their 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.db.mp = self.caller.db.max_mp # Set current MP to maximum + self.caller.location.msg_contents("%s rests to recover HP and MP." % self.caller) + # You'll probably want to replace this with your own system for recovering HP and MP. + +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." + char = self.caller + + if not char.db.max_hp: # Character not initialized, IE in unit tests + char.db.hp = 100 + char.db.max_hp = 100 + char.db.spells_known = [] + char.db.max_mp = 20 + char.db.mp = char.db.max_mp + + char.msg("You have %i / %i HP and %i / %i MP." % (char.db.hp, char.db.max_hp, char.db.mp, char.db.max_mp)) + +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 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(CmdLearnSpell()) + self.add(CmdCast()) + self.add(CmdStatus()) + +""" +---------------------------------------------------------------------------- +SPELL FUNCTIONS START HERE +---------------------------------------------------------------------------- + +These are the functions that are called by the 'cast' command to perform the +effects of various spells. Which spells execute which functions and what +parameters are passed to them are specified at the bottom of the module, in +the 'SPELLS' dictionary. + +All of these functions take the same arguments: + caster (obj): Character casting the spell + spell_name (str): Name of the spell being cast + targets (list): List of objects targeted by the spell + cost (int): MP cost of casting the spell + +These functions also all accept **kwargs, and how these are used is specified +in the docstring for each function. +""" + +def spell_healing(caster, spell_name, targets, cost, **kwargs): + """ + Spell that restores HP to a target or targets. + + kwargs: + healing_range (tuple): Minimum and maximum amount healed to + each target. (20, 40) by default. + """ + spell_msg = "%s casts %s!" % (caster, spell_name) + + min_healing = 20 + max_healing = 40 + + # Retrieve healing range from kwargs, if present + if "healing_range" in kwargs: + min_healing = kwargs["healing_range"][0] + max_healing = kwargs["healing_range"][1] + + for character in targets: + to_heal = randint(min_healing, max_healing) # Restore 20 to 40 hp + if character.db.hp + to_heal > character.db.max_hp: + to_heal = character.db.max_hp - character.db.hp # Cap healing to max HP + character.db.hp += to_heal + spell_msg += " %s regains %i HP!" % (character, to_heal) + + caster.db.mp -= cost # Deduct MP cost + + caster.location.msg_contents(spell_msg) # Message the room with spell results + + if is_in_combat(caster): # Spend action if in combat + spend_action(caster, 1, action_name="cast") + +def spell_attack(caster, spell_name, targets, cost, **kwargs): + """ + Spell that deals damage in combat. Similar to resolve_attack. + + kwargs: + attack_name (tuple): Single and plural describing the sort of + attack or projectile that strikes each enemy. + damage_range (tuple): Minimum and maximum damage dealt by the + spell. (10, 20) by default. + accuracy (int): Modifier to the spell's attack roll, determining + an increased or decreased chance to hit. 0 by default. + attack_count (int): How many individual attacks are made as part + of the spell. If the number of attacks exceeds the number of + targets, the first target specified will be attacked more + than once. Just 1 by default - if the attack_count is less + than the number targets given, each target will only be + attacked once. + """ + spell_msg = "%s casts %s!" % (caster, spell_name) + + atkname_single = "The spell" + atkname_plural = "spells" + min_damage = 10 + max_damage = 20 + accuracy = 0 + attack_count = 1 + + # Retrieve some variables from kwargs, if present + if "attack_name" in kwargs: + atkname_single = kwargs["attack_name"][0] + atkname_plural = kwargs["attack_name"][1] + if "damage_range" in kwargs: + min_damage = kwargs["damage_range"][0] + max_damage = kwargs["damage_range"][1] + if "accuracy" in kwargs: + accuracy = kwargs["accuracy"] + if "attack_count" in kwargs: + attack_count = kwargs["attack_count"] + + to_attack = [] + # If there are more attacks than targets given, attack first target multiple times + if len(targets) < attack_count: + to_attack = to_attack + targets + extra_attacks = attack_count - len(targets) + for n in range(extra_attacks): + to_attack.insert(0, targets[0]) + else: + to_attack = to_attack + targets + + + # Set up dictionaries to track number of hits and total damage + total_hits = {} + total_damage = {} + for fighter in targets: + total_hits.update({fighter:0}) + total_damage.update({fighter:0}) + + # Resolve attack for each target + for fighter in to_attack: + attack_value = randint(1, 100) + accuracy # Spell attack roll + defense_value = get_defense(caster, fighter) + if attack_value >= defense_value: + spell_dmg = randint(min_damage, max_damage) # Get spell damage + total_hits[fighter] += 1 + total_damage[fighter] += spell_dmg + + for fighter in targets: + # Construct combat message + if total_hits[fighter] == 0: + spell_msg += " The spell misses %s!" % fighter + elif total_hits[fighter] > 0: + attack_count_str = atkname_single + " hits" + if total_hits[fighter] > 1: + attack_count_str = "%i %s hit" % (total_hits[fighter], atkname_plural) + spell_msg += " %s %s for %i damage!" % (attack_count_str, fighter, total_damage[fighter]) + + caster.db.mp -= cost # Deduct MP cost + + caster.location.msg_contents(spell_msg) # Message the room with spell results + + for fighter in targets: + # Apply damage + apply_damage(fighter, total_damage[fighter]) + # If fighter HP is reduced to 0 or less, call at_defeat. + if fighter.db.hp <= 0: + at_defeat(fighter) + + if is_in_combat(caster): # Spend action if in combat + spend_action(caster, 1, action_name="cast") + +def spell_conjure(caster, spell_name, targets, cost, **kwargs): + """ + Spell that creates an object. + + kwargs: + obj_key (str): Key of the created object. + obj_desc (str): Desc of the created object. + obj_typeclass (str): Typeclass path of the object. + + If you want to make more use of this particular spell funciton, + you may want to modify it to use the spawner (in evennia.utils.spawner) + instead of creating objects directly. + """ + + obj_key = "a nondescript object" + obj_desc = "A perfectly generic object." + obj_typeclass = "evennia.objects.objects.DefaultObject" + + # Retrieve some variables from kwargs, if present + if "obj_key" in kwargs: + obj_key = kwargs["obj_key"] + if "obj_desc" in kwargs: + obj_desc = kwargs["obj_desc"] + if "obj_typeclass" in kwargs: + obj_typeclass = kwargs["obj_typeclass"] + + conjured_obj = create_object(obj_typeclass, key=obj_key, location=caster.location) # Create object + conjured_obj.db.desc = obj_desc # Add object desc + + caster.db.mp -= cost # Deduct MP cost + + # Message the room to announce the creation of the object + caster.location.msg_contents("%s casts %s, and %s appears!" % (caster, spell_name, conjured_obj)) + +""" +---------------------------------------------------------------------------- +SPELL DEFINITIONS START HERE +---------------------------------------------------------------------------- +In this section, each spell is matched to a function, and given parameters +that determine its MP cost, valid type and number of targets, and what +function casting the spell executes. + +This data is given as a dictionary of dictionaries - the key of each entry +is the spell's name, and the value is a dictionary of various options and +parameters, some of which are required and others which are optional. + +Required values for spells: + + cost (int): MP cost of casting the spell + target (str): Valid targets for the spell. Can be any of: + "none" - No target needed + "self" - Self only + "any" - Any object + "anyobj" - Any object that isn't a character + "anychar" - Any character + "other" - Any object excluding the caster + "otherchar" - Any character excluding the caster + spellfunc (callable): Function that performs the action of the spell. + Must take the following arguments: caster (obj), spell_name (str), + targets (list), and cost (int), as well as **kwargs. + +Optional values for spells: + + combat_spell (bool): If the spell can be cast in combat. True by default. + noncombat_spell (bool): If the spell can be cast out of combat. True by default. + max_targets (int): Maximum number of objects that can be targeted by the spell. + 1 by default - unused if target is "none" or "self" + +Any other values specified besides the above will be passed as kwargs to 'spellfunc'. +You can use kwargs to effectively re-use the same function for different but similar +spells - for example, 'magic missile' and 'flame shot' use the same function, but +behave differently, as they have different damage ranges, accuracy, amount of attacks +made as part of the spell, and so forth. If you make your spell functions flexible +enough, you can make a wide variety of spells just by adding more entries to this +dictionary. +""" + +SPELLS = { +"magic missile":{"spellfunc":spell_attack, "target":"otherchar", "cost":3, "noncombat_spell":False, "max_targets":3, + "attack_name":("A bolt", "bolts"), "damage_range":(4, 7), "accuracy":999, "attack_count":3}, + +"flame shot":{"spellfunc":spell_attack, "target":"otherchar", "cost":3, "noncombat_spell":False, + "attack_name":("A jet of flame", "jets of flame"), "damage_range":(25, 35)}, + +"cure wounds":{"spellfunc":spell_healing, "target":"anychar", "cost":5}, + +"mass cure wounds":{"spellfunc":spell_healing, "target":"anychar", "cost":10, "max_targets": 5}, + +"full heal":{"spellfunc":spell_healing, "target":"anychar", "cost":12, "healing_range":(100, 100)}, + +"cactus conjuration":{"spellfunc":spell_conjure, "target":"none", "cost":2, "combat_spell":False, + "obj_key":"a cactus", "obj_desc":"An ordinary green cactus with little spines."} +} + diff --git a/evennia/contrib/tutorial_world/objects.py b/evennia/contrib/tutorial_world/objects.py index 1e088aa8d0..f83462ad6b 100644 --- a/evennia/contrib/tutorial_world/objects.py +++ b/evennia/contrib/tutorial_world/objects.py @@ -674,7 +674,7 @@ class CrumblingWall(TutorialObject, DefaultExit): # we found the button by moving the roots result = ["Having moved all the roots aside, you find that the center of the wall, " "previously hidden by the vegetation, hid a curious square depression. It was maybe once " - "concealed and made to look a part of the wall, but with the crumbling of stone around it," + "concealed and made to look a part of the wall, but with the crumbling of stone around it, " "it's now easily identifiable as some sort of button."] elif self.db.exit_open: # we pressed the button; the exit is open diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py index 67367df3bb..8bff161cf5 100644 --- a/evennia/scripts/scripts.py +++ b/evennia/scripts/scripts.py @@ -18,6 +18,17 @@ from future.utils import with_metaclass __all__ = ["DefaultScript", "DoNothing", "Store"] +FLUSHING_INSTANCES = False # whether we're in the process of flushing scripts from the cache +SCRIPT_FLUSH_TIMERS = {} # stores timers for scripts that are currently being flushed + + +def restart_scripts_after_flush(): + """After instances are flushed, validate scripts so they're not dead for a long period of time""" + global FLUSHING_INSTANCES + ScriptDB.objects.validate() + FLUSHING_INSTANCES = False + + class ExtendedLoopingCall(LoopingCall): """ LoopingCall that can start at a delay different @@ -358,6 +369,27 @@ class DefaultScript(ScriptBase): return max(0, self.db_repeats - task.callcount) return None + def at_idmapper_flush(self): + """If we're flushing this object, make sure the LoopingCall is gone too""" + ret = super(DefaultScript, self).at_idmapper_flush() + if ret and self.ndb._task: + try: + from twisted.internet import reactor + global FLUSHING_INSTANCES + # store the current timers for the _task and stop it to avoid duplicates after cache flush + paused_time = self.ndb._task.next_call_time() + callcount = self.ndb._task.callcount + self._stop_task() + SCRIPT_FLUSH_TIMERS[self.id] = (paused_time, callcount) + # here we ensure that the restart call only happens once, not once per script + if not FLUSHING_INSTANCES: + FLUSHING_INSTANCES = True + reactor.callLater(2, restart_scripts_after_flush) + except Exception: + import traceback + traceback.print_exc() + return ret + def start(self, force_restart=False): """ Called every time the script is started (for persistent @@ -374,9 +406,19 @@ class DefaultScript(ScriptBase): started or not. Used in counting. """ - if self.is_active and not force_restart: - # script already runs and should not be restarted. + # The script is already running, but make sure we have a _task if this is after a cache flush + if not self.ndb._task and self.db_interval >= 0: + self.ndb._task = ExtendedLoopingCall(self._step_task) + try: + start_delay, callcount = SCRIPT_FLUSH_TIMERS[self.id] + del SCRIPT_FLUSH_TIMERS[self.id] + now = False + except (KeyError, ValueError, TypeError): + now = not self.db_start_delay + start_delay = None + callcount = 0 + self.ndb._task.start(self.db_interval, now=now, start_delay=start_delay, count_start=callcount) return 0 obj = self.obj diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 172fee8922..9c34a6165a 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -142,7 +142,7 @@ LOCKWARNING_LOG_FILE = os.path.join(LOG_DIR, 'lockwarnings.log') CYCLE_LOGFILES = True # Number of lines to append to rotating channel logs when they rotate CHANNEL_LOG_NUM_TAIL_LINES = 20 -# Max size of channel log files before they rotate +# Max size (in bytes) of channel log files before they rotate CHANNEL_LOG_ROTATE_SIZE = 1000000 # Local time zone for this installation. All choices can be found here: # http://www.postgresql.org/docs/8.0/interactive/datetime-keywords.html#DATETIME-TIMEZONE-SET-TABLE diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py index 25488f4987..2ff78d310e 100644 --- a/evennia/typeclasses/managers.py +++ b/evennia/typeclasses/managers.py @@ -653,6 +653,42 @@ class TypeclassManager(TypedObjectManager): """ return super(TypeclassManager, self).filter(db_typeclass_path=self.model.path).count() + def annotate(self, *args, **kwargs): + """ + Overload annotate method to filter on typeclass before annotating. + Args: + *args (any): Positional arguments passed along to queryset annotate method. + **kwargs (any): Keyword arguments passed along to queryset annotate method. + + Returns: + Annotated queryset. + """ + return super(TypeclassManager, self).filter(db_typeclass_path=self.model.path).annotate(*args, **kwargs) + + def values(self, *args, **kwargs): + """ + Overload values method to filter on typeclass first. + Args: + *args (any): Positional arguments passed along to values method. + **kwargs (any): Keyword arguments passed along to values method. + + Returns: + Queryset of values dictionaries, just filtered by typeclass first. + """ + return super(TypeclassManager, self).filter(db_typeclass_path=self.model.path).values(*args, **kwargs) + + def values_list(self, *args, **kwargs): + """ + Overload values method to filter on typeclass first. + Args: + *args (any): Positional arguments passed along to values_list method. + **kwargs (any): Keyword arguments passed along to values_list method. + + Returns: + Queryset of value_list tuples, just filtered by typeclass first. + """ + return super(TypeclassManager, self).filter(db_typeclass_path=self.model.path).values_list(*args, **kwargs) + def _get_subclasses(self, cls): """ Recursively get all subclasses to a class. diff --git a/evennia/utils/dbserialize.py b/evennia/utils/dbserialize.py index 80203923e8..c08704be0e 100644 --- a/evennia/utils/dbserialize.py +++ b/evennia/utils/dbserialize.py @@ -237,10 +237,13 @@ class _SaverList(_SaverMutable, MutableSequence): self._data = list() @_save - def __add__(self, otherlist): + def __iadd__(self, otherlist): self._data = self._data.__add__(otherlist) return self._data + def __add__(self, otherlist): + return list(self._data) + otherlist + @_save def insert(self, index, value): self._data.insert(index, self._convert_mutables(value))