diff --git a/evennia/contrib/tutorials/evadventure/combat_turnbased.py b/evennia/contrib/tutorials/evadventure/combat_turnbased.py index 9f77ca141e..f9e1f5b196 100644 --- a/evennia/contrib/tutorials/evadventure/combat_turnbased.py +++ b/evennia/contrib/tutorials/evadventure/combat_turnbased.py @@ -492,10 +492,15 @@ class EvAdventureCombatHandler(DefaultScript): # how many actions can be queued at a time (per combatant) max_action_queue_size = 1 - # available actions + # available actions in combat action_classes = { "nothing": CombatActionDoNothing, "attack": CombatActionAttack, + "stunt": CombatActionStunt, + "use": CombatActionUseItem, + "wield": CombatActionWield, + "flee": CombatActionFlee, + "hinder": CombatActionHinder, } # fallback action if not selecting anything @@ -509,7 +514,7 @@ class EvAdventureCombatHandler(DefaultScript): # who is involved in combat, and their action queue, # as {combatant: [actiondict, actiondict,...]} - combatants = AttributeProperty(defaultdict(list)) + combatants = AttributeProperty(defaultdict(deque)) advantage_matrix = AttributeProperty(defaultdict(dict)) disadvantage_matrix = AttributeProperty(defaultdict(dict)) @@ -549,21 +554,22 @@ class EvAdventureCombatHandler(DefaultScript): mapping={locobj.key: locobj for locobj in location_objs}, ) - def add_combatant(self, combatant): + def add_combatants(self, *combatants): """ Add a new combatant to the battle. Args: - combatant (EvAdventureCharacter, EvAdventureNPC): A combatant to add to + *combatants (EvAdventureCharacter, EvAdventureNPC): Any number of combatants to add to the combat. Returns: bool: True if the combatant was added, False otherwise (that is, they were already added from before). """ - if combatant not in self.combatants: - self.combatants[combatant] = deque((), self.max_action_queue_size) - return True + for combatant in combatants: + if combatant not in self.combatants: + self.combatants[combatant] = deque((), self.max_action_queue_size) + return True def remove_combatant(self, combatant): """ @@ -656,14 +662,14 @@ class EvAdventureCombatHandler(DefaultScript): queue will be rotated to the left and be `[b, c, a]` (so next time, `b` will be used). """ - queue = self.combatants[combatant] - action_dict = queue[0] if queue else COMBAT_ACTION_DICT_DONOTHING + action_queue = self.combatants[combatant] + action_dict = action_queue[0] if action_queue else COMBAT_ACTION_DICT_DONOTHING # rotate the queue to the left so that the first element is now the last one - queue.rotate(-1) + action_queue.rotate(-1) # use the action-dict to select and create an action from an action class action_class = self.action_classes[action_dict["key"]] - action = action_class(combatant, action_dict) + action = action_class(self, combatant, action_dict) action.execute() @@ -674,7 +680,8 @@ class EvAdventureCombatHandler(DefaultScript): """ self.turn += 1 # random turn order - combatants = random.shuffle(list(self.combatants.keys())) + combatants = list(self.combatants.keys()) + random.shuffle(combatants) # shuffles in place # do everyone's next queued combat action for combatant in combatants: @@ -704,11 +711,11 @@ class EvAdventureCombatHandler(DefaultScript): allies, enemies = (), () else: # grab a random survivor and check of they have any living enemies. - surviving_combatant = random.choice(list(self.combatant.keys())) + surviving_combatant = random.choice(list(self.combatants.keys())) allies, enemies = self.get_sides(surviving_combatant) if not enemies: - # one way or another, there are no more enemies to fight + # if one way or another, there are no more enemies to fight still_standing = list_to_string(f"$You({comb.key})" for comb in allies) knocked_out = list_to_string( f"$You({comb.key})" for comb in self.defeated_combatants if comb.hp > 0 @@ -729,6 +736,37 @@ class EvAdventureCombatHandler(DefaultScript): self.stop_combat() +def get_or_create_combathandler(combatant, combathandler_name="combathandler", combat_tick=5): + """ + Joins or continues combat. This is a access function that will either get the + combathandler on the current room or create a new one. + + Args: + combatant (EvAdventureCharacter, EvAdventureNPC): The one to + + Returns: + CombatHandler: The new or created combathandler. + + """ + + location = combatant.location + + if not location: + raise CombatFailure("Cannot start combat without a location.") + + combathandler = location.scripts.get(combathandler_name) + if not combathandler: + combathandler = create_script( + EvAdventureCombatHandler, + key=combathandler_name, + obj=location, + interval=combat_tick, + persistent=True, + ) + combathandler.add_combatants(combatant) + return combathandler + + # ------------------------------------------------------------ # # Tick-based fast combat (Diku-style) @@ -770,15 +808,10 @@ class _CmdCombatBase(Command): @property def combathandler(self): - self.combathandler = self.caller.location.scripts.get(self.combathandler_name) - if not self.combathandler: - self.combathandler = create_script( - EvAdventureCombatHandler, - key=combathandler_name, - obj=location, - interval=self.combat_tick, - persistent=True, - ) + combathandler = getattr(self, "combathandler", None) + if not combathandler: + self.combathandler = combathandler = get_or_create_combathandler(self.caller) + return combathandler def parse(self): super().parse() @@ -847,7 +880,7 @@ class CmdAttack(_CmdCombatBase): return # this can be done over and over - is_new = self.combathandler.add_combatant(self) + is_new = self.combathandler.add_combatants(self) if is_new: # just joined combat - add the combat cmdset self.caller.cmdset.add(CombatCmdSet) diff --git a/evennia/contrib/tutorials/evadventure/objects.py b/evennia/contrib/tutorials/evadventure/objects.py index 7d5decd5ea..c79d602e6a 100644 --- a/evennia/contrib/tutorials/evadventure/objects.py +++ b/evennia/contrib/tutorials/evadventure/objects.py @@ -146,6 +146,23 @@ class EvAdventureConsumable(EvAdventureObject): self.delete() +class EvAdventureWeapon(EvAdventureObject): + """ + Base weapon class for all EvAdventure weapons. + + """ + + obj_type = ObjType.WEAPON + inventory_use_slot = AttributeProperty(WieldLocation.WEAPON_HAND) + quality = AttributeProperty(3) + + # what ability used to attack with this weapon + attack_type = AttributeProperty(Ability.STR) + # what defense stat of the enemy it must defeat + defense_type = AttributeProperty(Ability.ARMOR) + damage_roll = AttributeProperty("1d6") + + class EvAdventureThrowable(EvAdventureWeapon, EvAdventureConsumable): """ Something you can throw at an enemy to harm them once, like a knife or exploding potion/grenade. @@ -179,23 +196,6 @@ class WeaponEmptyHand: return "" -class EvAdventureWeapon(EvAdventureObject): - """ - Base weapon class for all EvAdventure weapons. - - """ - - obj_type = ObjType.WEAPON - inventory_use_slot = AttributeProperty(WieldLocation.WEAPON_HAND) - quality = AttributeProperty(3) - - # what ability used to attack with this weapon - attack_type = AttributeProperty(Ability.STR) - # what defense stat of the enemy it must defeat - defense_type = AttributeProperty(Ability.ARMOR) - damage_roll = AttributeProperty("1d6") - - class EvAdventureRunestone(EvAdventureWeapon, EvAdventureConsumable): """ Base class for magic runestones. In _Knave_, every spell is represented by a rune stone diff --git a/evennia/contrib/tutorials/evadventure/tests/test_combat.py b/evennia/contrib/tutorials/evadventure/tests/test_combat.py index 0b20c2a21f..b2b2a51bb7 100644 --- a/evennia/contrib/tutorials/evadventure/tests/test_combat.py +++ b/evennia/contrib/tutorials/evadventure/tests/test_combat.py @@ -3,20 +3,22 @@ Test EvAdventure combat. """ -from unittest.mock import MagicMock, patch +from collections import deque +from unittest.mock import Mock, call, patch from evennia.utils import create from evennia.utils.test_resources import BaseEvenniaTest -from .. import combat_turnbased +from .. import combat_turnbased as combat from ..characters import EvAdventureCharacter from ..enums import WieldLocation from ..npcs import EvAdventureMob from ..objects import EvAdventureConsumable, EvAdventureRunestone, EvAdventureWeapon +from ..rooms import EvAdventureRoom from .mixins import EvAdventureMixin -class EvAdventureCombatHandlerTest(EvAdventureMixin, BaseEvenniaTest): +class EvAdventureCombatHandlerTest(BaseEvenniaTest): """ Test methods on the turn-based combat handler @@ -31,13 +33,19 @@ class EvAdventureCombatHandlerTest(EvAdventureMixin, BaseEvenniaTest): ) @patch( "evennia.contrib.tutorials.evadventure.combat_turnbased.delay", - new=MagicMock(return_value=None), + new=Mock(return_value=None), ) def setUp(self): super().setUp() + + self.location = create.create_object(EvAdventureRoom, key="testroom") + self.combatant = create.create_object( + EvAdventureCharacter, key="testchar", location=self.location + ) + self.location.allow_combat = True self.location.allow_death = True - self.combatant = self.character + self.target = create.create_object( EvAdventureMob, key="testmonster", @@ -45,8 +53,143 @@ class EvAdventureCombatHandlerTest(EvAdventureMixin, BaseEvenniaTest): attributes=(("is_idle", True),), ) - # this already starts turn 1 - self.combathandler = combat_turnbased.join_combat(self.combatant, self.target) + self.combathandler = combat.get_or_create_combathandler(self.combatant) + # add target to combat + self.combathandler.add_combatants(self.target) + + def test_combatanthandler_setup(self): + """Testing all is set up correctly in the combathandler""" + + chandler = self.combathandler + self.assertEqual(dict(chandler.combatants), {self.combatant: deque(), self.target: deque()}) + self.assertEqual( + dict(chandler.action_classes), + { + "nothing": combat.CombatActionDoNothing, + "attack": combat.CombatActionAttack, + "stunt": combat.CombatActionStunt, + "use": combat.CombatActionUseItem, + "wield": combat.CombatActionWield, + "flee": combat.CombatActionFlee, + "hinder": combat.CombatActionHinder, + }, + ) + self.assertEqual(chandler.flee_timeout, 1) + self.assertEqual(dict(chandler.advantage_matrix), {}) + self.assertEqual(dict(chandler.disadvantage_matrix), {}) + self.assertEqual(dict(chandler.fleeing_combatants), {}) + self.assertEqual(dict(chandler.defeated_combatants), {}) + + def test_combathandler_msg(self): + """Test sending messages to all in handler""" + + self.location.msg_contents = Mock() + + self.combathandler.msg("test_message") + + self.location.msg_contents.assert_called_with( + "test_message", + exclude=[], + from_obj=None, + mapping={"testchar": self.combatant, "testmonster": self.target}, + ) + + def test_remove_combatant(self): + """Remove a combatant.""" + + self.combathandler.remove_combatant(self.target) + + self.assertEqual(dict(self.combathandler.combatants), {self.combatant: deque()}) + + def test_stop_combat(self): + """Stopping combat, making sure combathandler is deleted.""" + + self.combathandler.stop_combat() + self.assertIsNone(self.combathandler.pk) + + def test_get_sides(self): + """Getting the sides of combat""" + + combatant2 = create.create_object( + EvAdventureCharacter, key="testchar2", location=self.location + ) + target2 = create.create_object( + EvAdventureMob, + key="testmonster2", + location=self.location, + attributes=(("is_idle", True),), + ) + self.combathandler.add_combatants(combatant2, target2) + + # allies to combatant + allies, enemies = self.combathandler.get_sides(self.combatant) + self.assertEqual((allies, enemies), ([combatant2], [self.target, target2])) + + # allies to monster + allies, enemies = self.combathandler.get_sides(self.target) + self.assertEqual((allies, enemies), ([target2], [self.combatant, combatant2])) + + def test_queue_and_execute_action(self): + """Queue actions and execute""" + + donothing = {"key": "nothing"} + + self.combathandler.queue_action(self.combatant, donothing) + self.assertEqual( + dict(self.combathandler.combatants), + {self.combatant: deque([donothing]), self.target: deque()}, + ) + + mock_action = Mock() + self.combathandler.action_classes["nothing"] = Mock(return_value=mock_action) + + self.combathandler.execute_next_action(self.combatant) + + self.combathandler.action_classes["nothing"].assert_called_with( + self.combathandler, self.combatant, donothing + ) + mock_action.execute.assert_called_once() + + def test_execute_full_turn(self): + """Run a full (passive) turn""" + + donothing = {"key": "nothing"} + + self.combathandler.queue_action(self.combatant, donothing) + self.combathandler.queue_action(self.target, donothing) + + self.combathandler.execute_next_action = Mock() + + self.combathandler.execute_full_turn() + + self.combathandler.execute_next_action.assert_has_calls( + [call(self.combatant), call(self.target)], any_order=True + ) + + def _get_action(self, action_dict, action_dict2={"key": "nothing"}): + + self.combathandler.queue_action(self.combatant, action_dict) + self.combathandler.queue_action(self.target, action_dict2) + + action_cls1 = self.combathandler.action_classes[action_dict["key"]] + action_cls2 = self.combathandler.action_classes[action_dict2["key"]] + + return action_cls1, action_cls2 + + def test_action__do_nothing(self): + """Do nothing""" + + actiondict = {"key": "nothing"} + + actioncls1, actioncls2 = self._get_action(actiondict, actiondict) + + self.assertEqual(actioncls1, actioncls2) + + self.combathandler.execute_full_turn() + + self.assertEqual(self.combathandler.turn, 1) + + # @patch("evennia.contrib.tutorials.evadventure.combat_turnbased.rules.randint") # class EvAdventureTurnbasedCombatHandlerTest(EvAdventureMixin, BaseEvenniaTest):