def test_after_effect_occurs_at_end_of_move(player, enemy, multi_use, duration, time_to_resolve): after_effect_calls = [] def after_effect(user, target): after_effect_calls.append(True) move = Move( build_subroutine(time_to_resolve=time_to_resolve, duration=duration, multi_use=multi_use, after_effect=after_effect), player, enemy) logic = CombatLogic([player, enemy]) logic.start_round([move]) assert not after_effect_calls for _ in range(time_to_resolve + duration): logic.end_round() assert not after_effect_calls logic.start_round([]) assert not after_effect_calls logic.end_round() assert after_effect_calls
def direct_damage(damage: int, cpu_slots: int = None, time_to_resolve: int = None, label: str = '') -> Subroutine: """Subroutine to deal direct damage. The subroutine's user cannot target itself. Args: damage: Damage dealt, must be non-negative. cpu_slots: CPU slots required. Default is 1 + damage // (2 * time_to_resolve+1). time_to_resolve: Time to resolve. Default is int(sqrt(damage)) label: A label prepended to the subroutine description. """ if damage < 0: raise ValueError('damage must be non-negative.') if time_to_resolve is None: time_to_resolve = int(math.sqrt(damage)) if cpu_slots is None: cpu_slots = 1 + damage // (2 * time_to_resolve + 1) def use_fun(user: Character, target: Character) -> None: damage_target(damage, target) def can_use_fun(user: Character, target: Character) -> bool: return not same_team(user, target) description = '{} damage'.format(damage, time_to_resolve) if label: description = label + ' ' + description return build_subroutine(use_fun, can_use_fun, cpu_slots, time_to_resolve, description)
def _heal_over_time() -> Subroutine: def use_fun(user: Character, target: Character) -> None: target.status.increment_attribute(Attributes.HEALTH, 1) def can_use(user: Character, target: Character) -> bool: health = target.status.get_attribute(Attributes.HEALTH) max_health = target.status.get_attribute(Attributes.MAX_HEALTH) return same_team(user, target) and health < max_health rounds = 3 desc = 'repair +1 for {} rounds'.format(rounds) return build_subroutine(use_fun, can_use, 1, 1, desc, rounds - 1, True)
def damage_over_time(damage_per_round: int, num_rounds: int = 2, cpu_slots: int = None, time_to_resolve: int = None, label: str = '') -> Subroutine: """Subroutine to deal damage over multiple rounds. The subroutine's user cannot target itself. Args: damage_per_round: Damage dealt per round, must be non-negative. num_rounds: The number of rounds that the damage is dealt. Default is 2. Must be positive. cpu_slots: CPU slots required. Default is 1 + damage // (2 * time_to_resolve+1). time_to_resolve: Number of rounds before first damage is dealt. Default is int(sqrt(damage)) label: A label prepended to the subroutine description. """ if damage_per_round < 0: raise ValueError('damage must be non-negative.') if num_rounds <= 0: raise ValueError('duration must be positive.') if time_to_resolve is None: time_to_resolve = int(math.sqrt(damage_per_round)) if cpu_slots is None: # CPU should grow with DPS, with less CPU for larger total time total_damage = damage_per_round * num_rounds total_time = time_to_resolve + num_rounds cpu_slots = 1 + total_damage // (2 * total_time) def use_fun(user: Character, target: Character) -> None: damage_target(damage_per_round, target) def can_use_fun(user: Character, target: Character) -> bool: return not same_team(user, target) description = '{} damage/{} turns'.format(damage_per_round * num_rounds, num_rounds) if label: description = label + ' ' + description return build_subroutine(use_fun, can_use_fun, cpu_slots, time_to_resolve, description, num_rounds - 1, multi_use=True)
def test_no_valid_moves_gives_default_move(self, ai_type): unusable = build_subroutine(can_use=False) data = CharacterData(ChassisData(subroutines_granted=(unusable,))) user = build_character(data=data) ai = build_ai(ai_type) ai.set_user(user) target = build_character(data=data) move_comps = set() for _ in range(100): move = ai.select_move([target]) sub = move.subroutine components = ( sub.cpu_slots(), sub.time_to_resolve(), sub.description()) move_comps.add(components) assert len(move_comps) == 1
def repair(amount: int) -> Subroutine: """Self repair subroutine. Args: amount: Amount to repair. Must be positive. """ assert amount > 0 def use_fun(user: Character, target: Character) -> None: target.status.increment_attribute(Attributes.HEALTH, amount) cpu_slots = max(1, amount // 2) time_slots = max(1, amount // 2) description = 'Repair {}'.format(amount) return build_subroutine(use_fun, user_is_target, cpu_slots, time_slots, description)
def shield_buff(amount: int, num_rounds: int = 1, cpu_slots: int = None, time_to_resolve: int = 0) -> Subroutine: """Adds a temporary damage shield buffer to the user or an ally. The shield fizzles either at the end of combat. Args: amount: Amount of shield added in a round. Must be non-negative. num_rounds: Number of rounds the buff is invoked. The total shield value does stack. Must be positive. cpu_slots: CPU slots required. By default this is max(floor(sqrt(amount * duration) - sqrt(time_to_resolve)), 0) time_to_resolve: Time before shield occurs. By default the shield is instantaneous. Must be non-negative. """ if amount < 0: raise ValueError('shield amount must be non-negative') if num_rounds < 1: raise ValueError('shield duration must be positive.') if time_to_resolve < 0: raise ValueError('shield time to resolve must be non-negative.') def use_fun(user: Character, target: Character) -> None: # Shield cannot decrease nor can it be made larger than amount. target.status.increment_attribute(Attributes.SHIELD, amount) if cpu_slots is None: cpu = math.sqrt(amount * num_rounds) - math.sqrt(time_to_resolve) cpu_slots = max(0, int(cpu)) description = '+{} shield'.format(amount) if num_rounds > 1: description += ' for {} rounds'.format(num_rounds) return build_subroutine(use_fun, same_team, cpu_slots, time_to_resolve, description, num_rounds - 1, multi_use=True)
def test_move_disappears_after_expected_time(player, enemy, multi_use, duration, time_to_resolve): move = Move( build_subroutine(time_to_resolve=time_to_resolve, duration=duration, multi_use=multi_use), player, enemy) logic = CombatLogic([player, enemy]) logic.start_round([move]) # A unique copy of the move is created so that it may be properly tracked. assert len(logic.all_moves_present()) == 1 move = logic.all_moves_present()[0] for _ in range(time_to_resolve + duration): logic.end_round() assert move in logic.all_moves_present() logic.start_round([]) assert move in logic.all_moves_present() logic.end_round() assert move not in logic.all_moves_present()
from events.events_base import (BasicEvents, EventListener, EventType, SelectCharacterEvent, SelectPlayerMoveEvent) from models.characters.character_base import Character from models.characters.character_examples import CharacterTypes from models.characters.character_impl import build_character from models.characters.conditions import is_dead from models.characters.moves_base import Move from models.characters.player import get_player from models.characters.states import Attributes, StatusEffect from models.characters.subroutines_base import build_subroutine from models.combat.combat_logic import CombatLogic from models.scenes.layouts import Layout from models.scenes.scenes_base import Resolution, Scene _wait_one_round = build_subroutine(can_use=True, num_cpu=0, time_to_resolve=1, description='wait one round') _ANIMATION_TIME_SECONDS = 1.0 _ticks_per_animation = FRAMES_PER_SECOND * _ANIMATION_TIME_SECONDS ANIMATION = True # Set to False during integration tests def _valid_moves(user: Character, targets: Sequence[Character]) -> List[Move]: """All valid moves from a user to a sequence of targets, ignoring CPU slots. """ return [ Move(sub, user, target) for sub, target in product(user.chassis.all_subroutines(), targets) if sub.can_use(user, target) and sub.cpu_slots() <= user.status.get_attribute(Attributes.CPU_AVAILABLE)
def adjust_attribute(attribute: Attributes, amount: int = 0, duration: int = 0, cpu_slots: int = None, time_to_resolve: int = 0, is_buff: bool = None) -> Subroutine: """Adjust a permanent attribute over a finite duration. Permanent attributes may not be reduced below zero. Args: attribute: Attribute to be modified. attribute.is_permanent must be True. amount: How much to change the attribute by. May be positive or negative. duration: Number of rounds over which attribute is modified. Must be non-negative. cpu_slots: Number of CPU slots required to maintain the adjustment. Default is math.abs(amount). time_to_resolve: Number of rounds before modification is applied. is_buff: Whether the adjustment is a 'buff' (i.e. helpful). If True, then it may only be used on allies. If False, it may only be used on enemies. By default the subroutine may be used on both. """ if duration < 0: raise ValueError('duration must be non-negative.') if not attribute.is_permanent: raise ValueError('Only permanent attributes may be modified.') description = '{} {}'.format(amount, attribute.value) if amount >= 0: description = '+' + description effect = StatusEffect.build(description, attribute_modifiers={attribute: amount}) def use_fun(user: Character, target: Character) -> None: target.status.add_status_effect(effect) def can_use(user: Character, target: Character) -> bool: if is_buff is None: return True elif is_buff: return same_team(user, target) else: return not same_team(user, target) def after_effect(user: Character, target: Character) -> None: # If some other subroutine removes the effect first, this can cause # an error. target.status.remove_status_effect(effect) if cpu_slots is None: cpu_slots = abs(amount) description += ' {} rounds'.format(duration) return build_subroutine(use_fun, can_use, cpu_slots, time_to_resolve, description, duration, multi_use=False, after_effect=after_effect)
_shoot_laser = direct_damage(2, label='small laser') _SINGLE_LASER = ChassisData(slot_capacities={ SlotTypes.HEAD: 1, SlotTypes.STORAGE: 1 }, states_granted=(State.ON_FIRE, ), attribute_modifiers={ Attributes.MAX_HEALTH: 5, Attributes.MAX_CPU: 2 }, subroutines_granted=(_shoot_laser, )) _do_nothing_1 = build_subroutine(can_use=True, description='Do nothing', time_to_resolve=1, num_cpu=0) _do_nothing_2 = build_subroutine(can_use=True, description='Do nothing', time_to_resolve=2, num_cpu=0) _unusable = build_subroutine(can_use=False) _HARMLESS = ChassisData(attribute_modifiers={ Attributes.MAX_HEALTH: 1, Attributes.MAX_CPU: 1 }, subroutines_granted=(_do_nothing_1, _do_nothing_2, _unusable)) _USELESS = ChassisData(attribute_modifiers={
"""Tests for CombatStack.""" from unittest.mock import Mock import pytest from models.characters.moves_base import Move from models.characters.subroutines_base import build_subroutine from models.combat.combat_stack import CombatStack time_1_sub = build_subroutine(time_to_resolve=1) time_2_sub = build_subroutine(time_to_resolve=2) time_3_sub = build_subroutine(time_to_resolve=3) move_1 = Mock(Move, name='1 turns', subroutine=time_1_sub) move_2 = Mock(Move, name='2 turns', subroutine=time_2_sub) move_3_A = Mock(Move, name='3 turns A', subroutine=time_3_sub) move_3_B = Mock(Move, name='3 turns B', subroutine=time_3_sub) def test_resolved_moves_after_init(): stack = CombatStack() assert stack.resolved_moves() == () def test_moves_remaining_correct_order(): stack = CombatStack() for m in [move_2, move_3_A, move_3_B, move_1]: stack.add_move(m, m.subroutine.time_to_resolve())
def test_subroutine_eq(): assert build_subroutine() != build_subroutine() sub = build_subroutine() assert sub == sub assert sub != sub.copy()