Source code for simfantasy.actor

import logging
from abc import abstractmethod
from datetime import datetime, timedelta
from math import floor
from typing import Any, Dict, Iterable, List, TYPE_CHECKING, Tuple, Union

import humanfriendly

from simfantasy.common_math import get_base_resources_by_job, get_base_stats_by_job, \
    get_racial_attribute_bonuses, main_stat_per_level, piety_per_level, sub_stat_per_level
from simfantasy.enum import Attribute, Job, Race, Resource, Role, Slot
from simfantasy.equipment import Item, Materia, Weapon
from simfantasy.simulator import Simulation

if TYPE_CHECKING:
    from simfantasy.aura import Aura

logger = logging.getLogger(__name__)


class TargetData:
    def __init__(self, sim: Simulation, source: 'Actor') -> None:
        pass


class Actions:
    def __init__(self, sim: Simulation, source: 'Actor') -> None:
        pass

    def invalidate_speed_caches(self):
        for _, action in vars(self).items():
            # Safety check in case classes dervied from Actor set instance attributes.
            if not hasattr(action, 'speed') or not hasattr(action.speed, '__wrapped__'):
                continue

            action.speed.cache_clear()


class Buffs:
    def __init__(self, sim: Simulation, source: 'Actor') -> None:
        pass


[docs]class Actor: """A participant in an encounter. Warnings: Although level is accepted as an argument, many of the formulae only work at level 70. This argument may be deprecated in the future, or at least restricted to max levels of each game version, i.e., 50, 60, 70 for A Realm Reborn, Heavensward, and Stormblood respectively, where it's more likely that someone spent the time to figure out all the math. Arguments: sim (simfantasy.simulator.Simulation): Pointer to the simulation that the actor is participating in. race (Optional[simfantasy.enum.Race]): Race and clan of the actor. level (Optional[int]): Level of the actor. target (Optional[simfantasy.actor.Actor]): The enemy that the actor is targeting. name (Optional[str]): Name of the actor. gear (Optional[Dict[~simfantasy.enum.Slot, Union[~simfantasy.equipment.Item, ~simfantasy.equipment.Weapon]]]): Collection of equipment that the actor is wearing. Attributes: _target_data (Dict[~simfantasy.actor.Actor, ~simfantasy.actor.TargetData): Mapping of actors to any available target state data. animation_unlock_at (datetime.datetime): Timestamp when the actor will be able to execute actions again without being inhibited by animation lockout. auras (List[simfantasy.aura.Aura]): Auras, both friendly and hostile, that exist on the actor. gcd_unlock_at (datetime.datetime): Timestamp when the actor will be able to execute GCD actions again without being inhibited by GCD lockout. gear (Optional[Dict[~simfantasy.enum.Slot, Union[~simfantasy.equipment.Item, ~simfantasy.equipment.Weapon]]]): Collection of equipment that the actor is wearing. job (simfantasy.enum.Job): The actor's job specialization. level (int): Level of the actor. name (str): Name of the actor. race (simfantasy.enum.Race): Race and clan of the actor. resources (Dict[~simfantasy.enums.Resource, Tuple[int, int]]): Mapping of resource type to a tuple containing the current amount and maximum capacity. sim (simfantasy.simulator.Simulation): Pointer to the simulation that the actor is participating in. statistics (Dict[str, List[Dict[Any, Any]]]): Collection of different event occurrences that are used for reporting and visualizations. stats (Dict[~simfantasy.enums.Attribute, int]): Mapping of attribute type to amount. target (simfantasy.actor.Actor): The enemy that the actor is targeting. """ job: Job = None role: Role = None # TODO Get rid of level? def __init__(self, sim: Simulation, race: Race = None, level: int = None, target: 'Actor' = None, name: str = None, gear: Dict[Slot, Union[Item, Weapon]] = None) -> None: if level is None: level = 70 if gear is None: gear = {} if name is None: name = humanfriendly.text.random_string(length=10) self.sim: Simulation = sim self.race: Race = race self.level: int = level self.target: 'Actor' = target self.name: str = name self._target_data: Dict['Actor', TargetData] = {} self.actions = None self.auras: List[Aura] = [] self.buffs: Buffs = None self.animation_unlock_at: datetime = None self.gcd_unlock_at: datetime = None self.statistics: Dict[str, List[Dict[str, Any]]] = {} self.stats: Dict[Attribute, int] = {} self.gear: Dict[Slot, Union[Item, Weapon]] = {} self.equip_gear(gear) self.resources: Dict[Resource, Tuple[int, int]] = {} self.sim.actors.append(self) logger.debug('Initialized: %s', self)
[docs] def arise(self): """Prepare the actor for combat.""" self.auras.clear() self.stats = self.calculate_base_stats() self.apply_gear_attribute_bonuses() self.resources = self.calculate_resources() self.statistics = { 'auras': [], 'damage': [], 'resources': [], } self.animation_unlock_at = None self.gcd_unlock_at = None self._target_data.clear() self.create_actions() self.create_buffs()
def create_actions(self): self.actions = Actions(self.sim, self) def create_buffs(self): self.buffs = Buffs(self.sim, self) def create_target_data(self): self._target_data[self.target] = TargetData(self.sim, self)
[docs] def calculate_resources(self): """Determine the resource levels for the actor. In particular, sets the HP, MP and TP resource levels. """ main_stat = main_stat_per_level[self.level] job_resources = get_base_resources_by_job(self.job) # FIXME It's broken. # @formatter:off hp = floor(3600 * (job_resources[Resource.HP] / 100)) + floor( (self.stats[Attribute.VITALITY] - main_stat) * 21.5) mp = floor((job_resources[Resource.MP] / 100) * ((6000 * (self.stats[Attribute.PIETY] - 292) / 2170) + 12000)) # @formatter:on return { Resource.HP: (hp, hp), Resource.MP: (mp, mp), Resource.TP: (1000, 1000), # TODO Math for this? }
@property def target_data(self) -> TargetData: """Return target state data. For new targets, or at least ones that the actor has never switched to before, there will not be any target data available. In that scenario, this property initializes a new instance of the target data class and returns it. If there is already target state data, it will be returned directly. Returns: simfantasy.actor.TargetData: Contains all the target state data from the source actor to the target. """ try: return self._target_data[self.target] except KeyError: self.create_target_data() return self.target_data @property def gcd_up(self) -> bool: """Determine if the actor is GCD locked. The global cooldown, or GCD, is a 2.5s lockout that prevents other GCD actions from being performed. Actions on the GCD are constrained by their "execute time", or :math:`\\max_{GCD, CastTime}`. Returns: bool: True if the actor is still GCD locked, False otherwise. Examples: .. testsetup:: >>> sim = Simulation() >>> sim.current_time = datetime.now() >>> actor = Actor(sim) Consider an actor that has just performed some action, and is thus gcd locked for 2.5s. During this period, the actor will be unable to perform actions that are also on the GCD: >>> actor.gcd_unlock_at = sim.current_time + timedelta(seconds=2.5) >>> actor.gcd_up False However, once the simulation's game clock advances past the GCD lockout timestamp, the actor can once again perform GCD actions: >>> sim.current_time += timedelta(seconds=3) >>> actor.gcd_up True """ return self.gcd_unlock_at is None or self.gcd_unlock_at <= self.sim.current_time @property def animation_up(self) -> bool: """Determine if the actor is animation locked. Many actions have an animation timing of 0.75s. This locks out the actor from performing multiple oGCD actions simultaneously. This lockout is tracked and can inhibit actions from being performed accordingly. Returns: bool: True if the actor is still animation locked, False otherwise. Examples: .. testsetup:: >>> sim = Simulation() >>> sim.current_time = datetime.now() >>> actor = Actor(sim) Consider an actor that has just performed some action, and is thus animation locked for 0.75s. During this period, the actor will be unable to perform actions that also have animation timings: >>> actor.animation_unlock_at = sim.current_time + timedelta(seconds=0.75) >>> actor.animation_up False However, once the simulation's game clock advances past the animation lockout timestamp, the actor can once again perform actions: >>> sim.current_time += timedelta(seconds=1) >>> actor.animation_up True """ return self.animation_unlock_at is None or self.animation_unlock_at <= self.sim.current_time
[docs] def equip_gear(self, gear: Dict[Slot, Union[Weapon, Item]]): """Equip items in the appropriate slots.""" for slot, item in gear.items(): if not slot & item.slot: raise Exception('Tried to place equipment in an incorrect slot.') self.gear[slot] = item
[docs] def apply_gear_attribute_bonuses(self): """Apply stat bonuses gained from items and melds. Examples: .. testsetup:: >>> sim = Simulation() >>> actor = Actor(sim) Consider the `Kujakuo Kai`_ bow for Bards: >>> kujakuo_kai = Weapon(item_level=370, name='Kujakuo Kai', physical_damage=104, magic_damage=70, ... auto_attack=105.38, delay=3.04, ... stats={ ... Attribute.DEXTERITY: 347, ... Attribute.VITALITY: 380, ... Attribute.CRITICAL_HIT: 218, ... Attribute.DIRECT_HIT: 311, ... }) Equipping this item will add its stat bonuses to the actor: >>> actor.equip_gear({Slot.WEAPON: kujakuo_kai}) >>> actor.apply_gear_attribute_bonuses() >>> actor.stats[Attribute.DEXTERITY] == 347 True Bonuses from melded materia are also applied: >>> savage_aim_vi = Materia(Attribute.CRITICAL_HIT, 40) >>> kujakuo_kai.melds = [savage_aim_vi, savage_aim_vi] >>> actor.stats = {} >>> actor.equip_gear({Slot.WEAPON: kujakuo_kai}) >>> actor.apply_gear_attribute_bonuses() >>> actor.stats[Attribute.CRITICAL_HIT] == 218 + 40 + 40 True .. _Kujakuo Kai: https://na.finalfantasyxiv.com/lodestone/playguide/db/item/81019e5dbd4/ """ for slot, item in self.gear.items(): for gear_stat, bonus in item.stats.items(): if gear_stat not in self.stats: self.stats[gear_stat] = 0 self.stats[gear_stat] += bonus for materia in item.melds: if materia.attribute not in self.stats: self.stats[materia.attribute] = 0 self.stats[materia.attribute] += materia.bonus
[docs] @abstractmethod def decide(self) -> Iterable: """Given current simulation environment, decide what action should be performed, if any. The "decision engine" for each actor is a generator function that yields the desired actions. This method should be constructed as a priority list, where more important actions are towards the top, and less important actions towards the bottom. A notable exception is for filler spells, i.e. :class:`~simfantasy.event.MeleeAttackAction` and :class:`~simfantasy.melee.ShotAction`. Auto-attack actions don't interfere with other skills and happen at regular intervals, so they can (and should) be safely placed at top priority. See Also: Refer to :func:`simfantasy.event.ActorReadyEvent.execute` for clarification on what happens with actions yielded from the decision engine. Yields: Optional[simfantasy.action.Action]: An instance of an action that will attempt to be performed. If None is yielded, no further attempts to find a suitable action will be made until the actor is ready again. """ yield
[docs] def calculate_base_stats(self) -> Dict[Attribute, int]: """Calculate and set base primary and secondary stats. Base stats are determined by a combination of level, job and race/clan affiliation. Returns: Dict[Attribute, int]: Mapping of attributes to amounts. """ base_main_stat = main_stat_per_level[self.level] base_sub_stat = sub_stat_per_level[self.level] base_stats = { Attribute.STRENGTH: 0, Attribute.DEXTERITY: 0, Attribute.VITALITY: 0, Attribute.INTELLIGENCE: 0, Attribute.MIND: 0, Attribute.CRITICAL_HIT: base_sub_stat, Attribute.DETERMINATION: base_main_stat, Attribute.DIRECT_HIT: base_sub_stat, Attribute.SKILL_SPEED: base_sub_stat, Attribute.TENACITY: base_sub_stat, Attribute.PIETY: base_main_stat, } job_stats = get_base_stats_by_job(self.job) race_stats = get_racial_attribute_bonuses(self.race) for stat, bonus in job_stats.items(): base_stats[stat] += floor(base_main_stat * (bonus / 100)) + race_stats[stat] if self.role is Role.HEALER: base_stats[Attribute.PIETY] += piety_per_level[self.level] return base_stats
def __str__(self): return '<{cls} name={name}>'.format(cls=self.__class__.__name__, name=self.name)