Source code for simfantasy.event

import logging
import queue
from abc import ABCMeta, abstractmethod
from datetime import datetime, timedelta
from math import floor
from typing import List

import numpy

from simfantasy.aura import Aura, TickingAura
from simfantasy.common_math import divisor_per_level, get_base_stats_by_job, \
    main_stat_per_level, sub_stat_per_level
from simfantasy.enum import Attribute, Job, RefreshBehavior, Resource, Slot
from simfantasy.simulator import Simulation

logger = logging.getLogger(__name__)


[docs]class Event(metaclass=ABCMeta): """Emitted objects corresponding to in-game occurrences.""" def __init__(self, sim: Simulation): """ Create a new event. :param sim: The simulation that the event is fired within. """ self.sim = sim self.timestamp: datetime = None self.unscheduled = False def __lt__(self, other: 'Event') -> bool: """ Comparison for determining if one Event is less than another. Required for sorting the event heap. Returns :param other: The other event to compare to. :return: True if current event occurs before other. """ return self.timestamp < other.timestamp def __str__(self) -> str: """String representation of the object.""" return '<{cls}>'.format(cls=self.__class__.__name__)
[docs] @abstractmethod def execute(self) -> None: """Handle the event appropriately when popped off the heap queue."""
class CombatStartEvent(Event): def __init__(self, sim: Simulation): super().__init__(sim) self.sim.current_time = self.sim.start_time = datetime.now() def execute(self) -> None: for actor in self.sim.actors: logger.debug('[%s] ^^ %s %s arises', self.sim.current_iteration, self.sim.relative_timestamp, actor) actor.arise()
[docs]class CombatEndEvent(Event): """An event indicating that combat has ceased."""
[docs] def execute(self) -> None: """Clear any remaining events in the heap.""" self.sim.events = queue.PriorityQueue()
[docs]class AuraEvent(Event, metaclass=ABCMeta): """ An event that deals with an "aura", i.e., a buff or debuff that can be applied to an :class:`~simfantasy.actor.Actor`. """ def __init__(self, sim: Simulation, target, aura: Aura): """ Create a new event. :param sim: The simulation that the event is fired within. :param target: The :class:`~simfantasy.actor.Actor` context in which to evaluate the aura. :param aura: The aura that will interact with the target. """ super().__init__(sim) self.target = target self.aura = aura def __str__(self) -> str: """String representation of the object.""" return '<{cls} aura={aura} target={target}>'.format( cls=self.__class__.__name__, aura=self.aura.name, target=self.target.name )
[docs]class ApplyAuraEvent(AuraEvent): """An event indicating that an aura should be added to an :class:`~simfantasy.actor.Actor`."""
[docs] def execute(self) -> None: """Add the aura to the target and fire any post-application hooks from the aura itself.""" self.aura.apply(self.target) self.target.statistics['auras'].append({ 'iteration': self.sim.current_iteration, 'timestamp': self.sim.current_time, 'target': self.target.name, 'aura': self.aura.name, 'application': True, 'expiration': False, 'consumption': False, 'refresh': False, })
[docs]class ExpireAuraEvent(AuraEvent): """An event indicating that an aura should be removed from an :class:`~simfantasy.actor.Actor`."""
[docs] def execute(self) -> None: """Remove the aura if still present on the target and fire any post-expiration hooks from the aura itself.""" self.aura.expire(self.target) self.aura.expiration_event = None self.target.statistics['auras'].append({ 'iteration': self.sim.current_iteration, 'timestamp': self.sim.current_time, 'target': self.target.name, 'aura': self.aura.name, 'application': False, 'expiration': True, 'consumption': False, 'refresh': False, })
[docs]class ActorReadyEvent(Event): """An event indicating that an :class:`~simfantasy.actor.Actor` is ready to perform new actions.""" def __init__(self, sim: Simulation, actor): """ Create a new event. :param sim: The simulation that the event is fired within. :param actor: The :class:`~simfantasy.actor.Actor` context, i.e, the one recovering from nonready state. """ super().__init__(sim) self.actor = actor def execute(self) -> None: decision_engine = self.actor.decide() for decision in decision_engine: if decision is not None: try: decision_action, decision_options = decision except TypeError: decision_action, decision_options = decision, None if decision_action.ready and ( decision_options is None or decision_options() is True): decision_action.perform() return elif self.sim.log_action_attempts is True: if decision_action.can_recast_at is not None and decision_action.can_recast_at > self.sim.current_time: logger.debug('[%s] ## %s %s attempted %s but on cooldown (recast=%s)', self.sim.current_iteration, self.sim.relative_timestamp, self.actor, decision_action, decision_action.can_recast_at - self.sim.start_time) elif self.actor.animation_unlock_at > self.sim.current_time: logger.debug('[%s] ## %s %s attempted %s but animation locked (unlock=%s)', self.sim.current_iteration, self.sim.relative_timestamp, self.actor, decision_action, self.actor.animation_unlock_at - self.sim.start_time) elif not decision_action.is_off_gcd and self.actor.gcd_unlock_at > self.sim.current_time: logger.debug('[%s] ## %s %s attempted %s but gcd locked (unlock=%s)', self.sim.current_iteration, self.sim.relative_timestamp, self.actor, decision_action, self.actor.gcd_unlock_at - self.sim.start_time) elif decision_options is not None and decision_options() is False: logger.debug('[%s] ## %s %s attempted %s but failed conditions', self.sim.current_iteration, self.sim.relative_timestamp, self.actor, decision_action) else: return # Got nothing from the actor, so try again in 100ms. self.sim.schedule(self, timedelta(seconds=0.1)) if self.sim.log_action_attempts is True: logger.debug('[%s] ## %s No decision by %s (animation_unlock_at=%s gcd_unlock_at=%s)', self.sim.current_iteration, self.sim.relative_timestamp, self.actor, self.actor.animation_unlock_at - self.sim.start_time, self.actor.gcd_unlock_at - self.sim.start_time) def __str__(self): """String representation of the object.""" return '<{cls} actor={actor}>'.format( cls=self.__class__.__name__, actor=self.actor.name )
class RefreshAuraEvent(AuraEvent): def __init__(self, sim: Simulation, target, aura: Aura): super().__init__(sim, target, aura) self.remains = self.aura.expiration_event.timestamp - self.sim.current_time def execute(self) -> None: if self.aura.refresh_behavior is RefreshBehavior.RESET: delta = self.aura.duration elif self.aura.refresh_behavior is RefreshBehavior.EXTEND_TO_MAX: delta = max(self.aura.duration, self.sim.current_time - self.aura.expiration_event + self.aura.refresh_extension) else: delta = self.aura.duration self.aura.expire(self.target) self.aura.apply(self.target) self.aura.expiration_event = ExpireAuraEvent(self.sim, self.target, self.aura) self.sim.schedule(self.aura.expiration_event, delta) self.target.statistics['auras'].append({ 'iteration': self.sim.current_iteration, 'timestamp': self.sim.current_time, 'target': self.target.name, 'aura': self.aura.name, 'application': False, 'expiration': False, 'consumption': False, 'refresh': True, }) def __str__(self) -> str: return '<{cls} aura={aura} target={target} behavior={behavior} remains={remains}>'.format( cls=self.__class__.__name__, aura=self.aura.name, target=self.target.name, behavior=self.aura.refresh_behavior, remains=format(self.remains.total_seconds(), '.3f') ) class ConsumeAuraEvent(AuraEvent): def __init__(self, sim: Simulation, target, aura: Aura): super().__init__(sim, target, aura) self.remains = self.aura.expiration_event.timestamp - self.sim.current_time def execute(self) -> None: self.aura.expire(self.target) self.sim.unschedule(self.aura.expiration_event) self.aura.expiration_event = None self.target.statistics['auras'].append({ 'iteration': self.sim.current_iteration, 'timestamp': self.sim.current_time, 'target': self.target.name, 'aura': self.aura.name, 'application': False, 'expiration': False, 'consumption': True, 'refresh': False, }) class DamageEvent(Event): def __init__(self, sim: Simulation, source, target, action, potency: int, trait_multipliers: List[float] = None, buff_multipliers: List[float] = None, guarantee_crit: bool = None): super().__init__(sim) if trait_multipliers is None: trait_multipliers = [] if buff_multipliers is None: buff_multipliers = [] self.source = source self.target = target self.action = action self.potency = potency self.trait_multipliers = trait_multipliers self.buff_multipliers = buff_multipliers self._damage = None self._is_critical_hit = guarantee_crit """ Deferred attribute. Set once unless cached value is invalidated. True if the ability was determined to be a critical hit. """ self._is_direct_hit = None """ Deferred attribute. Set once unless cached value is invalidated. True if the ability was determined to be a direct hit. """ def execute(self): self.source.statistics['damage'].append({ 'iteration': self.sim.current_iteration, 'timestamp': self.sim.current_time, 'source': self.source.name, 'target': self.target.name, 'action': self.action.name, 'damage': self.damage, 'critical': self.is_critical_hit, 'direct': self.is_direct_hit, 'dot': False, }) @property def critical_hit_chance(self) -> float: """ Calculate the critical hit probability. :return: A float in the range [0, 1]. """ sub_stat = sub_stat_per_level[self.source.level] divisor = divisor_per_level[self.source.level] p_chr = floor( 200 * (self.source.stats[Attribute.CRITICAL_HIT] - sub_stat) / divisor + 50) / 1000 return p_chr @property def is_critical_hit(self) -> bool: """ Check for a cached value and set if being evaluated for the first time. :return: True if the ability is a critical hit. """ if self._is_critical_hit is None: if self.critical_hit_chance >= 100: self._is_critical_hit = True elif self.critical_hit_chance <= 0: self._is_critical_hit = False else: self._is_critical_hit = numpy.random.uniform() <= self.critical_hit_chance return self._is_critical_hit @property def direct_hit_chance(self): """ Calculate the direct hit probability. :return: A float in the range [0, 1]. """ sub_stat = sub_stat_per_level[self.source.level] divisor = divisor_per_level[self.source.level] p_dhr = floor(550 * (self.source.stats[Attribute.DIRECT_HIT] - sub_stat) / divisor) / 1000 return p_dhr @property def is_direct_hit(self): """ Check for a cached value and set if being evaluated for the first time. :return: True if the ability is a direct hit. """ if self._is_direct_hit is None: if self.direct_hit_chance >= 100: self._is_direct_hit = True elif self.direct_hit_chance <= 0: self._is_direct_hit = False else: self._is_direct_hit = numpy.random.uniform() <= self.direct_hit_chance return self._is_direct_hit @property def damage(self) -> int: """ Calculate the damage dealt directly to the target by the ability. Accounts for criticals, directs, and randomization. :return: The damage inflicted as an integer value. """ if self._damage is not None: return self._damage base_stats = get_base_stats_by_job(self.source.job) if self.action.powered_by is Attribute.ATTACK_POWER: if self.source.job is Job.BARD \ or self.source.job is Job.MACHINIST \ or self.source.job is Job.NINJA: job_attribute_modifier = base_stats[Attribute.DEXTERITY] attack_rating = self.source.stats[Attribute.DEXTERITY] else: job_attribute_modifier = base_stats[Attribute.STRENGTH] attack_rating = self.source.stats[Attribute.STRENGTH] weapon_damage = self.source.gear[Slot.WEAPON].physical_damage elif self.action.powered_by is Attribute.ATTACK_MAGIC_POTENCY: if self.source.job is Job.ASTROLOGIAN \ or self.source.job is Job.SCHOLAR \ or self.source.job is Job.WHITE_MAGE: job_attribute_modifier = base_stats[Attribute.MIND] attack_rating = self.source.stats[Attribute.MIND] else: job_attribute_modifier = base_stats[Attribute.INTELLIGENCE] attack_rating = self.source.stats[Attribute.INTELLIGENCE] weapon_damage = self.source.gear[Slot.WEAPON].magic_damage elif self.action.powered_by is Attribute.HEALING_MAGIC_POTENCY: job_attribute_modifier = base_stats[Attribute.MIND] weapon_damage = self.source.gear[Slot.WEAPON].magic_damage attack_rating = self.source.stats[Attribute.MIND] else: raise Exception('Action affected by unexpected attribute.') main_stat = main_stat_per_level[self.source.level] sub_stat = sub_stat_per_level[self.source.level] divisor = divisor_per_level[self.source.level] f_ptc = self.potency / 100 f_wd = floor((main_stat * job_attribute_modifier / 1000) + weapon_damage) f_atk = floor((125 * (attack_rating - 292) / 292) + 100) / 100 f_det = floor( 130 * (self.source.stats[Attribute.DETERMINATION] - main_stat) / divisor + 1000) / 1000 f_tnc = floor( 100 * (self.source.stats[Attribute.TENACITY] - sub_stat) / divisor + 1000) / 1000 f_chr = floor( 200 * (self.source.stats[Attribute.CRITICAL_HIT] - sub_stat) / divisor + 1400) / 1000 damage_randomization = numpy.random.uniform(0.95, 1.05) damage = f_ptc * f_wd * f_atk * f_det * f_tnc for m in self.trait_multipliers: damage *= m damage = floor(damage) damage = floor(damage * (f_chr if self.is_critical_hit else 1)) damage = floor(damage * (1.25 if self.is_direct_hit else 1)) damage = floor(damage * damage_randomization) for m in self.buff_multipliers: damage = floor(damage * m) self._damage = int(damage) return self._damage def __str__(self) -> str: """String representation of the object.""" return '<{cls} source={source} target={target} action={action} crit={crit} direct={direct} damage={damage} traits={traits} buffs={buffs}>'.format( cls=self.__class__.__name__, source=self.source.name, target=self.target.name, action=self.action.name, crit=self.is_critical_hit, direct=self.is_direct_hit, damage=self.damage, traits=self.trait_multipliers, buffs=self.buff_multipliers, ) class DotTickEvent(DamageEvent): def __init__(self, sim: Simulation, source, target, action, potency: int, aura: TickingAura, ticks_remain: int = None, trait_multipliers: List[float] = None, buff_multipliers: List[float] = None): super().__init__(sim, source, target, action, potency, trait_multipliers, buff_multipliers) self.aura = aura self.action = action if ticks_remain is None: ticks_remain = self.aura.ticks self.ticks_remain = ticks_remain def execute(self) -> None: self.source.statistics['damage'].append({ 'iteration': self.sim.current_iteration, 'timestamp': self.sim.current_time, 'source': self.source.name, 'target': self.target.name, 'action': self.action.name, 'damage': self.damage, 'critical': self.is_critical_hit, 'direct': self.is_direct_hit, 'dot': True, }) self.ticks_remain -= 1 if self.ticks_remain > 0: self.sim.schedule(self, timedelta(seconds=3)) @property def damage(self) -> int: if self._damage is not None: return self._damage base_stats = get_base_stats_by_job(self.source.job) if self.action.powered_by is Attribute.ATTACK_POWER: if self.source.job is Job.BARD \ or self.source.job is Job.MACHINIST \ or self.source.job is Job.NINJA: job_attribute_modifier = base_stats[Attribute.DEXTERITY] attack_rating = self.source.stats[Attribute.DEXTERITY] else: job_attribute_modifier = base_stats[Attribute.STRENGTH] attack_rating = self.source.stats[Attribute.STRENGTH] weapon_damage = self.source.gear[Slot.WEAPON].physical_damage elif self.action.powered_by is Attribute.ATTACK_MAGIC_POTENCY: if self.source.job is Job.ASTROLOGIAN \ or self.source.job is Job.SCHOLAR \ or self.source.job is Job.WHITE_MAGE: job_attribute_modifier = base_stats[Attribute.MIND] attack_rating = self.source.stats[Attribute.MIND] else: job_attribute_modifier = base_stats[Attribute.INTELLIGENCE] attack_rating = self.source.stats[Attribute.INTELLIGENCE] weapon_damage = self.source.gear[Slot.WEAPON].magic_damage elif self.action.powered_by is Attribute.HEALING_MAGIC_POTENCY: job_attribute_modifier = base_stats[Attribute.MIND] weapon_damage = self.source.gear[Slot.WEAPON].magic_damage attack_rating = self.source.stats[Attribute.MIND] else: raise Exception('Action affected by unexpected attribute.') main_stat = main_stat_per_level[self.source.level] sub_stat = sub_stat_per_level[self.source.level] divisor = divisor_per_level[self.source.level] f_ptc = self.potency / 100 f_wd = floor((main_stat * job_attribute_modifier / 1000) + weapon_damage) f_atk = floor((125 * (attack_rating - 292) / 292) + 100) / 100 f_det = floor( 130 * (self.source.stats[Attribute.DETERMINATION] - main_stat) / divisor + 1000) / 1000 f_tnc = floor( 100 * (self.source.stats[Attribute.TENACITY] - sub_stat) / divisor + 1000) / 1000 f_ss = floor( 130 * (self.source.stats[self.action.hastened_by] - sub_stat) / divisor + 1000) / 1000 f_chr = floor( 200 * (self.source.stats[Attribute.CRITICAL_HIT] - sub_stat) / divisor + 1400) / 1000 damage_randomization = numpy.random.uniform(0.95, 1.05) damage = f_ptc * f_wd * f_atk * f_det * f_tnc for m in self.trait_multipliers: damage *= m damage = floor(damage) damage = floor(damage * f_ss) damage = floor(damage * (f_chr if self.is_critical_hit else 1)) damage = floor(damage * (1.25 if self.is_direct_hit else 1)) damage = floor(damage * damage_randomization) for m in self.buff_multipliers: damage = floor(damage * m) self._damage = int(damage) return self._damage def __str__(self): return '<{cls} source={source} target={target} action={action} crit={crit} direct={direct} damage={damage} ticks_remain={ticks_remain}>'.format( cls=self.__class__.__name__, source=self.source.name, target=self.target.name, action=self.action.name, crit=self.is_critical_hit, direct=self.is_direct_hit, damage=self.damage, ticks_remain=self.ticks_remain, ) class ResourceEvent(Event): def __init__(self, sim: Simulation, target, resource: Resource, amount: int): super().__init__(sim) self.target = target self.resource = resource self.amount = amount def execute(self) -> None: current, maximum = self.target.resources[self.resource] final_resource = max(min(current + self.amount, maximum), 0) self.target.resources[self.resource] = (final_resource, maximum) self.target.statistics['resources'].append({ 'iteration': self.sim.current_iteration, 'timestamp': self.sim.current_time, 'target': self.target.name, 'resource': self.resource, 'amount': self.amount, 'level': final_resource, }) def __str__(self): return '<{cls} target={target} resource={resource} amount={amount}>'.format( cls=self.__class__.__name__, target=self.target.name, resource=self.resource, amount=self.amount, ) class ServerTickEvent(Event): def execute(self) -> None: super().execute() for actor in self.sim.actors: current_mp, max_mp = actor.resources[Resource.MP] current_tp, max_tp = actor.resources[Resource.TP] if current_mp < max_mp: mp_tick = int(floor(0.02 * max_mp)) self.sim.schedule( ResourceEvent(self.sim, actor, Resource.MP, mp_tick)) # TODO Tick rate? if current_tp < max_tp: self.sim.schedule( ResourceEvent(self.sim, actor, Resource.TP, 60)) # TODO Tick rate? class ApplyAuraStackEvent(AuraEvent): def execute(self) -> None: if self.aura.stacks < self.aura.max_stacks: self.aura.stacks += 1 class AutoAttackEvent(DamageEvent): @property def damage(self) -> int: if self._damage is not None: return self._damage base_stats = get_base_stats_by_job(self.source.job) if self.source.job is Job.BARD \ or self.source.job is Job.MACHINIST \ or self.source.job is Job.NINJA: job_attribute_modifier = base_stats[Attribute.DEXTERITY] attack_rating = self.source.stats[Attribute.DEXTERITY] else: job_attribute_modifier = base_stats[Attribute.STRENGTH] attack_rating = self.source.stats[Attribute.STRENGTH] weapon_damage = self.source.gear[Slot.WEAPON].physical_damage weapon_delay = self.source.gear[Slot.WEAPON].delay main_stat = main_stat_per_level[self.source.level] sub_stat = sub_stat_per_level[self.source.level] divisor = divisor_per_level[self.source.level] f_ptc = self.potency / 100 f_aa = floor( floor((main_stat * job_attribute_modifier / 1000) + weapon_damage) * (weapon_delay / 3)) f_atk = floor((125 * (attack_rating - 292) / 292) + 100) / 100 f_det = floor( 130 * (self.source.stats[Attribute.DETERMINATION] - main_stat) / divisor + 1000) / 1000 f_tnc = floor( 100 * (self.source.stats[Attribute.TENACITY] - sub_stat) / divisor + 1000) / 1000 f_chr = floor( 200 * (self.source.stats[Attribute.CRITICAL_HIT] - sub_stat) / divisor + 1400) / 1000 damage_randomization = numpy.random.uniform(0.95, 1.05) damage = f_ptc * f_aa * f_atk * f_det * f_tnc for m in self.trait_multipliers: damage *= m damage = floor(damage) damage = floor(damage * (f_chr if self.is_critical_hit else 1)) damage = floor(damage * (1.25 if self.is_direct_hit else 1)) damage = floor(damage * damage_randomization) for m in self.buff_multipliers: damage = floor(damage * m) self._damage = int(damage) return self._damage