diff --git a/evennia/contrib/tutorials/evadventure/combat_turnbased.py b/evennia/contrib/tutorials/evadventure/combat_turnbased.py index 86c01b4154..3d6cbf8ba5 100644 --- a/evennia/contrib/tutorials/evadventure/combat_turnbased.py +++ b/evennia/contrib/tutorials/evadventure/combat_turnbased.py @@ -28,8 +28,7 @@ nothing. Available actions: 3. Make [S]tunt (gain/give advantage/disadvantage for future attacks) 4. S[W]ap weapon / spell rune 5. [U]se -6. [F]lee/disengage (takes two turns) -7. [B]lock from fleeing +6. [F]lee/disengage (takes one turn, during which attacks have advantage against you) 8. [H]esitate/Do nothing You can also use say/emote between rounds. @@ -118,8 +117,6 @@ from .objects import EvAdventureObject COMBAT_HANDLER_KEY = "evadventure_turnbased_combathandler" COMBAT_HANDLER_INTERVAL = 30 -COMBAT_ACTION_DICT_DONOTHING = {"key": "nothing", "desc": "Do nothing"} - class CombatFailure(RuntimeError): """ @@ -168,10 +165,14 @@ class CombatAction: self.combathandler.disadvantage_matrix[recipient][target] = True def has_advantage(self, recipient, target): - return bool(self.combathandler.advantage_matrix[recipient].pop(target, False)) + return bool(self.combathandler.advantage_matrix[recipient].pop(target, False)) or ( + target in self.combathandler.fleeing_combatants + ) def has_disadvantage(self, recipient, target): - return bool(self.combathandler.disadvantage_matrix[recipient].pop(target, False)) + return bool(self.combathandler.disadvantage_matrix[recipient].pop(target, False)) or ( + recipient in self.combathandler.fleeing_combatants + ) def lose_advantage(self, recipient, target): self.combathandler.advantage_matrix[recipient][target] = False @@ -179,14 +180,6 @@ class CombatAction: def lose_disadvantage(self, recipient, target): self.combathandler.disadvantage_matrix[recipient][target] = False - def flee(self, fleer): - if fleer not in self.combathandler.fleeing_combatants: - # we record the turn on which we started fleeing - self.combathandler.fleeing_combatants[fleer] = self.combathandler.turn - - def unflee(self, fleer): - self.combathandler.fleeing_combatants.pop(fleer, None) - def msg(self, message, broadcast=True): """ Convenience route to the combathandler msg-sender mechanism. @@ -217,6 +210,13 @@ class CombatAction: """ pass + def post_execute(self): + """ + Called after execution. + """ + # most actions abort ongoing fleeing actions. + self.combathandler.fleeing_combatants.pop(self.combatant, None) + class CombatActionDoNothing(CombatAction): """ @@ -392,52 +392,22 @@ class CombatActionFlee(CombatAction): """ def execute(self): + + if self.combatant not in self.combathandler.fleeing_combatants: + # we record the turn on which we started fleeing + self.combathandler.fleeing_combatants[self.combatant] = self.combathandler.turn + + flee_timeout = self.combathandler.flee_timeout self.msg( - "$You() $conj(retreat), and will leave combat next round unless someone successfully " - "blocks the escape." - ) - self.flee(self.combatant) - - -class CombatActionHinder(CombatAction): - """ - Hinder a fleeing opponent from fleeing/disengaging from combat. - - action_dict = { - "key": "hinder", - "target": Character/NPC - } - - Note: - Refer to as 'hinder' - - """ - - def execute(self): - - hinderer = self.combatant - target = self.target - - is_success, _, txt = rules.dice.opposed_saving_throw( - hinderer, - target, - attack_type=Ability.DEX, - defense_type=Ability.DEX, - advantage=self.has_advantage(hinderer, target), - disadvantage=self.has_disadvantage(hinderer, target), + "$You() $conj(retreat), leaving yourself exposed while doing so (will escape in " + f"{flee_timeout} $pluralize(turn, {flee_timeout}))." ) - # handle result - self.msg( - f"$You() $conj(try) to block the retreat of $You({target.key}). {txt}", - ) - if is_success: - # managed to stop the target from fleeing/disengaging - self.unflee(target) - self.msg(f"$You() $conj(block) the retreat of $You({target.key})") - else: - # failed to hinder the target - self.msg(f"$You({target.key}) $conj(dodge) away from you $You()!") + def post_execute(self): + """ + We override the default since we don't want to cancel fleeing here. + """ + pass class EvAdventureCombatHandler(DefaultScript): @@ -447,9 +417,6 @@ class EvAdventureCombatHandler(DefaultScript): """ - # how many actions can be queued at a time (per combatant) - max_action_queue_size = 1 - # available actions in combat action_classes = { "nothing": CombatActionDoNothing, @@ -458,18 +425,20 @@ class EvAdventureCombatHandler(DefaultScript): "use": CombatActionUseItem, "wield": CombatActionWield, "flee": CombatActionFlee, - "hinder": CombatActionHinder, } + # how many actions can be queued at a time (per combatant) + max_action_queue_size = 1 + # fallback action if not selecting anything - fallback_action = "attack" + fallback_action_dict = {"key": "nothing"} + + # how many turns you must be fleeing before escaping + flee_timeout = 1 # persistent storage turn = AttributeProperty(0) - # how many turns you must be fleeing before escaping - flee_timeout = AttributeProperty(1) - # who is involved in combat, and their action queue, # as {combatant: [actiondict, actiondict,...]} combatants = AttributeProperty(dict) @@ -617,7 +586,7 @@ class EvAdventureCombatHandler(DefaultScript): """ action_queue = self.combatants[combatant] - action_dict = action_queue[0] if action_queue else COMBAT_ACTION_DICT_DONOTHING + action_dict = action_queue[0] if action_queue else self.fallback_action_dict # rotate the queue to the left so that the first element is now the last one action_queue.rotate(-1) @@ -626,6 +595,7 @@ class EvAdventureCombatHandler(DefaultScript): action = action_class(self, combatant, action_dict) action.execute() + action.post_execute() def execute_full_turn(self): """ @@ -653,9 +623,9 @@ class EvAdventureCombatHandler(DefaultScript): # check if anyone managed to flee flee_timeout = self.flee_timeout - for combatant, started_fleeing in dict(self.fleeing_combatants): - if self.turn - started_fleeing > flee_timeout: - # if they are still alive/fleeing and started fleeing >1 round ago, they succeed + for combatant, started_fleeing in self.fleeing_combatants.items(): + if self.turn - started_fleeing >= flee_timeout: + # if they are still alive/fleeing and have been fleeing long enough, escape self.msg("|y$You() successfully $conj(flee) from combat.|n", combatant=combatant) self.remove_combatant(combatant) diff --git a/evennia/contrib/tutorials/evadventure/tests/test_combat.py b/evennia/contrib/tutorials/evadventure/tests/test_combat.py index 530e0944e2..58569349ab 100644 --- a/evennia/contrib/tutorials/evadventure/tests/test_combat.py +++ b/evennia/contrib/tutorials/evadventure/tests/test_combat.py @@ -95,7 +95,6 @@ class EvAdventureCombatHandlerTest(BaseEvenniaTest): "use": combat.CombatActionUseItem, "wield": combat.CombatActionWield, "flee": combat.CombatActionFlee, - "hinder": combat.CombatActionHinder, }, ) self.assertEqual(chandler.flee_timeout, 1) @@ -213,12 +212,6 @@ class EvAdventureCombatHandlerTest(BaseEvenniaTest): self.assertFalse(action.has_advantage(combatant, target)) self.assertFalse(action.has_disadvantage(combatant, target)) - action.flee(combatant) - self.assertIn(combatant, self.combathandler.fleeing_combatants) - - action.unflee(combatant) - self.assertNotIn(combatant, self.combathandler.fleeing_combatants) - action.msg(f"$You() attack $You({target.key}).") combatant.msg.assert_called_with(text=("You attack testmonster.", {}), from_obj=combatant) @@ -382,330 +375,32 @@ class EvAdventureCombatHandlerTest(BaseEvenniaTest): self.assertEqual(self.combatant.equipment.slots[WieldLocation.WEAPON_HAND], sword) self.assertEqual(self.combatant.equipment.slots[WieldLocation.TWO_HANDS], None) + def test_flee__success(self): + """ + Test fleeing twice, leading to leaving combat. -# def test_flee__success(self): -# """ -# Test fleeing twice, leading to leaving combat. -# -# """ -# # first flee records the fleeing state -# self._run_action(combat_turnbased.CombatActionFlee, None) -# self.assertTrue(self.combatant in self.combathandler.fleeing_combatants) -# -# # second flee should remove combatant -# self._run_action(combat_turnbased.CombatActionFlee, None) -# self.assertIsNone(self.combathandler.pk) -# -# @patch("evennia.contrib.tutorials.evadventure.combat_turnbased.rules.randint") -# def test_flee__blocked(self, mock_randint): -# """ """ -# mock_randint.return_value = 11 # means block will succeed -# -# self._run_action(combat_turnbased.CombatActionFlee, None) -# self.assertTrue(self.combatant in self.combathandler.fleeing_combatants) -# -# # other combatant blocks in the same turn -# self.combathandler.register_action( -# self.combatant, combat_turnbased.CombatActionFlee.key, None -# ) -# self.combathandler.register_action( -# self.target, combat_turnbased.CombatActionBlock.key, self.combatant -# ) -# self.combathandler._end_turn() -# # the fleeing combatant should remain now -# self.assertTrue(self.combatant not in self.combathandler.fleeing_combatants) -# self.assertTrue(self.combatant in self.combathandler.combatants) + """ + self.assertEqual(self.combathandler.turn, 0) + action_dict = {"key": "flee"} -# class EvAdventureTurnbasedCombatHandlerTest(EvAdventureMixin, BaseEvenniaTest): -# """ -# Test methods on the turn-based combat handler. -# -# """ -# -# maxDiff = None -# -# # make sure to mock away all time-keeping elements -# @patch( -# "evennia.contrib.tutorials.evadventure.combat_turnbased" -# ".EvAdventureCombatHandler.interval", -# new=-1, -# ) -# @patch( -# "evennia.contrib.tutorials.evadventure.combat_turnbased.delay", -# new=MagicMock(return_value=None), -# ) -# def setUp(self): -# super().setUp() -# self.location.allow_combat = True -# self.location.allow_death = True -# self.combatant = self.character -# self.target = create.create_object( -# EvAdventureMob, -# key="testmonster", -# location=self.location, -# attributes=(("is_idle", True),), -# ) -# -# # this already starts turn 1 -# self.combathandler = combat_turnbased.join_combat(self.combatant, self.target) -# -# def tearDown(self): -# self.combathandler.delete() -# self.target.delete() -# -# def test_remove_combatant(self): -# self.assertTrue(bool(self.combatant.db.combathandler)) -# self.combathandler.remove_combatant(self.combatant) -# self.assertFalse(self.combatant in self.combathandler.combatants) -# self.assertFalse(bool(self.combatant.db.combathandler)) -# -# def test_start_turn(self): -# self.combathandler._start_turn() -# self.assertEqual(self.combathandler.turn, 2) -# self.combathandler._start_turn() -# self.assertEqual(self.combathandler.turn, 3) -# -# def test_end_of_turn__empty(self): -# self.combathandler._end_turn() -# -# def test_add_combatant(self): -# self.combathandler._init_menu = MagicMock() -# combatant3 = create.create_object(EvAdventureCharacter, key="testcharacter3") -# self.combathandler.add_combatant(combatant3) -# -# self.assertTrue(combatant3 in self.combathandler.combatants) -# self.combathandler._init_menu.assert_called_once() -# -# def test_start_combat(self): -# self.combathandler._start_turn = MagicMock() -# self.combathandler.start = MagicMock() -# self.combathandler.start_combat() -# self.combathandler._start_turn.assert_called_once() -# self.combathandler.start.assert_called_once() -# -# def test_combat_summary(self): -# result = self.combathandler.get_combat_summary(self.combatant) -# self.assertTrue("You (4 / 4 health)" in result) -# self.assertTrue("testmonster" in result) -# -# def test_msg(self): -# self.location.msg_contents = MagicMock() -# self.combathandler.msg("You hurt the target", combatant=self.combatant) -# self.location.msg_contents.assert_called_with( -# "You hurt the target", -# from_obj=self.combatant, -# exclude=[], -# mapping={"testchar": self.combatant, "testmonster": self.target}, -# ) -# -# def test_gain_advantage(self): -# self.combathandler.gain_advantage(self.combatant, self.target) -# self.assertTrue(bool(self.combathandler.advantage_matrix[self.combatant][self.target])) -# -# def test_gain_disadvantage(self): -# self.combathandler.gain_disadvantage(self.combatant, self.target) -# self.assertTrue(bool(self.combathandler.disadvantage_matrix[self.combatant][self.target])) -# -# def test_flee(self): -# self.combathandler.flee(self.combatant) -# self.assertTrue(self.combatant in self.combathandler.fleeing_combatants) -# -# def test_unflee(self): -# self.combathandler.unflee(self.combatant) -# self.assertFalse(self.combatant in self.combathandler.fleeing_combatants) -# -# def test_register_and_run_action(self): -# action_class = combat_turnbased.CombatActionAttack -# action = self.combathandler.combatant_actions[self.combatant][action_class.key] -# -# self.combathandler.register_action(self.combatant, action.key) -# -# self.assertEqual(self.combathandler.action_queue[self.combatant], (action, (), {})) -# -# action.use = MagicMock() -# -# self.combathandler._end_turn() -# action.use.assert_called_once() -# -# def test_get_available_actions(self): -# result = self.combathandler.get_available_actions(self.combatant) -# self.assertTrue(len(result), 7) -# -# -# class EvAdventureTurnbasedCombatActionTest(EvAdventureMixin, BaseEvenniaTest): -# """ -# Test actions in turn_based combat. -# """ -# -# @patch( -# "evennia.contrib.tutorials.evadventure.combat_turnbased" -# ".EvAdventureCombatHandler.interval", -# new=-1, -# ) -# @patch( -# "evennia.contrib.tutorials.evadventure.combat_turnbased.delay", -# new=MagicMock(return_value=None), -# ) -# def setUp(self): -# super().setUp() -# self.location.allow_combat = True -# self.location.allow_death = True -# self.combatant = self.character -# self.combatant2 = create.create_object(EvAdventureCharacter, key="testcharacter2") -# self.target = create.create_object( -# EvAdventureMob, key="testmonster", attributes=(("is_idle", True),) -# ) -# self.target.hp = 4 -# -# # this already starts turn 1 -# self.combathandler = combat_turnbased.join_combat(self.combatant, self.target) -# -# def _run_action(self, action, *args, **kwargs): -# self.combathandler.register_action(self.combatant, action.key, *args, **kwargs) -# self.combathandler._end_turn() -# -# def test_do_nothing(self): -# self.combathandler.msg = MagicMock() -# self._run_action(combat_turnbased.CombatActionDoNothing, None) -# self.combathandler.msg.assert_called() -# -# @patch("evennia.contrib.tutorials.evadventure.combat_turnbased.rules.randint") -# def test_attack__miss(self, mock_randint): -# mock_randint.return_value = 8 # target has default armor 11, so 8+1 str will miss -# self._run_action(combat_turnbased.CombatActionAttack, self.target) -# self.assertEqual(self.target.hp, 4) -# -# @patch("evennia.contrib.tutorials.evadventure.combat_turnbased.rules.randint") -# def test_attack__success__still_alive(self, mock_randint): -# mock_randint.return_value = 11 # 11 + 1 str will hit beat armor 11 -# # make sure target survives -# self.target.hp = 20 -# self._run_action(combat_turnbased.CombatActionAttack, self.target) -# self.assertEqual(self.target.hp, 9) -# -# @patch("evennia.contrib.tutorials.evadventure.combat_turnbased.rules.randint") -# def test_attack__success__kill(self, mock_randint): -# mock_randint.return_value = 11 # 11 + 1 str will hit beat armor 11 -# self._run_action(combat_turnbased.CombatActionAttack, self.target) -# self.assertEqual(self.target.hp, -7) -# # after this the combat is over -# self.assertIsNone(self.combathandler.pk) -# -# @patch("evennia.contrib.tutorials.evadventure.combat_turnbased.rules.randint") -# def test_stunt_fail(self, mock_randint): -# mock_randint.return_value = 8 # fails 8+1 dex vs DEX 11 defence -# self._run_action(combat_turnbased.CombatActionStunt, self.target) -# self.assertEqual(self.combathandler.advantage_matrix[self.combatant], {}) -# self.assertEqual(self.combathandler.disadvantage_matrix[self.combatant], {}) -# -# @patch("evennia.contrib.tutorials.evadventure.combat_turnbased.rules.randint") -# def test_stunt_advantage__success(self, mock_randint): -# mock_randint.return_value = 11 # 11+1 dex vs DEX 11 defence is success -# self._run_action(combat_turnbased.CombatActionStunt, self.target) -# self.assertEqual( -# bool(self.combathandler.advantage_matrix[self.combatant][self.target]), True -# ) -# -# @patch("evennia.contrib.tutorials.evadventure.combat_turnbased.rules.randint") -# def test_stunt_disadvantage__success(self, mock_randint): -# mock_randint.return_value = 11 # 11+1 dex vs DEX 11 defence is success -# action = combat_turnbased.CombatActionStunt -# action.give_advantage = False -# self._run_action( -# action, -# self.target, -# ) -# self.assertEqual( -# bool(self.combathandler.disadvantage_matrix[self.target][self.combatant]), True -# ) -# -# def test_use_item(self): -# """ -# Use up a potion during combat. -# -# """ -# item = create.create_object( -# EvAdventureConsumable, key="Healing potion", attributes=[("uses", 2)] -# ) -# self.assertEqual(item.uses, 2) -# self._run_action(combat_turnbased.CombatActionUseItem, item, self.combatant) -# self.assertEqual(item.uses, 1) -# self._run_action(combat_turnbased.CombatActionUseItem, item, self.combatant) -# self.assertEqual(item.pk, None) # deleted, it was used up -# -# def test_swap_wielded_weapon_or_spell(self): -# """ -# First draw a weapon (from empty fists), then swap that out to another weapon, then -# swap to a spell rune. -# -# """ -# sword = create.create_object(EvAdventureWeapon, key="sword") -# zweihander = create.create_object( -# EvAdventureWeapon, -# key="zweihander", -# attributes=(("inventory_use_slot", WieldLocation.TWO_HANDS),), -# ) -# runestone = create.create_object(EvAdventureRunestone, key="ice rune") -# -# # check hands are empty -# self.assertEqual(self.combatant.weapon.key, "Empty Fists") -# self.assertEqual(self.combatant.equipment.slots[WieldLocation.WEAPON_HAND], None) -# self.assertEqual(self.combatant.equipment.slots[WieldLocation.TWO_HANDS], None) -# -# # swap to sword -# self._run_action(combat_turnbased.CombatActionSwapWieldedWeaponOrSpell, None, sword) -# self.assertEqual(self.combatant.weapon, sword) -# self.assertEqual(self.combatant.equipment.slots[WieldLocation.WEAPON_HAND], sword) -# self.assertEqual(self.combatant.equipment.slots[WieldLocation.TWO_HANDS], None) -# -# # swap to zweihander (two-handed sword) -# self._run_action(combat_turnbased.CombatActionSwapWieldedWeaponOrSpell, None, zweihander) -# self.assertEqual(self.combatant.weapon, zweihander) -# self.assertEqual(self.combatant.equipment.slots[WieldLocation.WEAPON_HAND], None) -# self.assertEqual(self.combatant.equipment.slots[WieldLocation.TWO_HANDS], zweihander) -# -# # swap to runestone (also using two hands) -# self._run_action(combat_turnbased.CombatActionSwapWieldedWeaponOrSpell, None, runestone) -# self.assertEqual(self.combatant.weapon, runestone) -# self.assertEqual(self.combatant.equipment.slots[WieldLocation.WEAPON_HAND], None) -# self.assertEqual(self.combatant.equipment.slots[WieldLocation.TWO_HANDS], runestone) -# -# # swap back to normal one-handed sword -# self._run_action(combat_turnbased.CombatActionSwapWieldedWeaponOrSpell, None, sword) -# self.assertEqual(self.combatant.weapon, sword) -# self.assertEqual(self.combatant.equipment.slots[WieldLocation.WEAPON_HAND], sword) -# self.assertEqual(self.combatant.equipment.slots[WieldLocation.TWO_HANDS], None) -# -# def test_flee__success(self): -# """ -# Test fleeing twice, leading to leaving combat. -# -# """ -# # first flee records the fleeing state -# self._run_action(combat_turnbased.CombatActionFlee, None) -# self.assertTrue(self.combatant in self.combathandler.fleeing_combatants) -# -# # second flee should remove combatant -# self._run_action(combat_turnbased.CombatActionFlee, None) -# self.assertIsNone(self.combathandler.pk) -# -# @patch("evennia.contrib.tutorials.evadventure.combat_turnbased.rules.randint") -# def test_flee__blocked(self, mock_randint): -# """ """ -# mock_randint.return_value = 11 # means block will succeed -# -# self._run_action(combat_turnbased.CombatActionFlee, None) -# self.assertTrue(self.combatant in self.combathandler.fleeing_combatants) -# -# # other combatant blocks in the same turn -# self.combathandler.register_action( -# self.combatant, combat_turnbased.CombatActionFlee.key, None -# ) -# self.combathandler.register_action( -# self.target, combat_turnbased.CombatActionBlock.key, self.combatant -# ) -# self.combathandler._end_turn() -# # the fleeing combatant should remain now -# self.assertTrue(self.combatant not in self.combathandler.fleeing_combatants) -# self.assertTrue(self.combatant in self.combathandler.combatants) + # first flee records the fleeing state + self._run_actions(action_dict) + self.assertEqual(self.combathandler.turn, 1) + self.assertEqual(self.combathandler.fleeing_combatants[self.combatant], 1) + + self.combatant.msg.assert_called_with( + text=( + "You retreat, leaving yourself exposed while doing so (will escape in 1 turn).", + {}, + ), + from_obj=self.combatant, + ) + # Check that enemies have advantage against you now + action = combat.CombatAction(self.combathandler, self.target, {"key": "nothing"}) + self.assertTrue(action.has_advantage(self.target, self.combatant)) + + # second flee should remove combatant + self._run_actions(action_dict) + # this ends combat, so combathandler should be gone + self.assertIsNone(self.combathandler.pk) diff --git a/evennia/utils/funcparser.py b/evennia/utils/funcparser.py index 03afb52610..985fc33268 100644 --- a/evennia/utils/funcparser.py +++ b/evennia/utils/funcparser.py @@ -1489,4 +1489,5 @@ ACTOR_STANCE_CALLABLES = { "conj": funcparser_callable_conjugate, "pron": funcparser_callable_pronoun, "Pron": funcparser_callable_pronoun_capitalize, + **FUNCPARSER_CALLABLES, }