diff --git a/fflogsapi/constants.py b/fflogsapi/constants.py index d3e2456..5261c1a 100644 --- a/fflogsapi/constants.py +++ b/fflogsapi/constants.py @@ -35,6 +35,8 @@ class EventType(Enum): REMOVE_DEBUFF = 'removedebuff' LB_UPDATE = 'limitbreakupdate' ENCOUNTER_END = 'encounterend' + TARGETABILITY_UPDATE = 'targetabilityupdate' + HEAD_MARKER = 'headmarker' # FF Logs uses millisecond precision in its timestamps diff --git a/fflogsapi/data/__init__.py b/fflogsapi/data/__init__.py index 2af527d..1694570 100644 --- a/fflogsapi/data/__init__.py +++ b/fflogsapi/data/__init__.py @@ -10,6 +10,8 @@ FFLogsReportComboRanking, FFLogsReportRanking, FFLogsReportTag, FFLogsZoneEncounterRanking, FFLogsZoneRanking, FFMap,) +from .phases import PhaseType, PhaseInformation, OmegaPhaseData, AlexanderPhaseData, ALL_PHASE_DATA + __all__ = [ # dataclasses.py 'FFLogsAllStarsRanking', @@ -36,4 +38,11 @@ 'FFLogsNPCData', 'FFGameZone', 'FFLogsPartition', + + # .phases + 'PhaseType', + 'PhaseInformation', + 'OmegaPhaseData', + 'AlexanderPhaseData', + 'ALL_PHASE_DATA', ] diff --git a/fflogsapi/data/phases/__init__.py b/fflogsapi/data/phases/__init__.py new file mode 100644 index 0000000..278045c --- /dev/null +++ b/fflogsapi/data/phases/__init__.py @@ -0,0 +1,31 @@ +''' +Custom implementation of tracking phases in fights such as ultimates +''' + +from .phases import PhaseType, PhaseInformation +from .omega import OmegaPhaseData +from .alexander import AlexanderPhaseData + +# I'm not super happy with these being singletons +# Should look into a better way to handle this +OmegaPhaseData = OmegaPhaseData() +AlexanderPhaseData = AlexanderPhaseData() + +ALL_PHASE_DATA = [ + OmegaPhaseData, + AlexanderPhaseData, +] + +__all__ = [ + # phases.py + 'PhaseType', + 'PhaseInformation', + + # omega.py + 'OmegaPhaseData', + + # alexander.py + 'AlexanderPhaseData', + + 'ALL_PHASE_DATA', +] diff --git a/fflogsapi/data/phases/alexander.py b/fflogsapi/data/phases/alexander.py new file mode 100644 index 0000000..10a9f5b --- /dev/null +++ b/fflogsapi/data/phases/alexander.py @@ -0,0 +1,129 @@ +from ...constants import EventType +from .phases import FightPhaseData, PhaseTransition, PhaseTransitionDefinition +from dataclasses import dataclass + + +@dataclass +class AlexanderPhaseData(FightPhaseData): + encounter_id: int = 1062 + + phases: tuple[str] = ( + 'Living Liquid', + 'Brute Justice and Cruise Chaser', + 'Alexander Prime', + 'Perfect Alexander', + ) + + intermissions: tuple[str] = ( + 'Limit Cut', + 'Temporal Stasis', + 'Inception Formation', + 'Wormhole Formation', + 'P4 Transition', + 'Fate Calibration Alpha', + 'Fate Calibration Beta', + ) + + phase_definitions: tuple[tuple[int, int, list[PhaseTransition]]] = ( + # LC + PhaseTransitionDefinition( + description='P1 ends, limit cut starts', + game_id=2000032, + event_def={'type': EventType.CAST.value, 'abilityGameID': 18480}, + transition_types=[PhaseTransition.END, PhaseTransition.INTERMISSION_START] + ), + + # P2 + PhaseTransitionDefinition( + description='Limit cut ends, P2 starts', + game_id=11340, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 1}, + transition_types=[PhaseTransition.INTERMISSION_END, PhaseTransition.START] + ), + + # Temporal stasis + PhaseTransitionDefinition( + description='P2 ends, temporal stasis starts', + game_id=11340, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 0}, + transition_types=[PhaseTransition.END, PhaseTransition.INTERMISSION_START] + ), + PhaseTransitionDefinition( + description='Temporal stasis ends, P3 begins', + game_id=11347, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 1}, + transition_types=[PhaseTransition.INTERMISSION_END, PhaseTransition.START] + ), + + # Inception + PhaseTransitionDefinition( + description='Inception formation begins', + game_id=11347, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 0}, + transition_types=[PhaseTransition.INTERMISSION_START] + ), + PhaseTransitionDefinition( + description='Inception formation ends', + game_id=11347, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 1}, + transition_types=[PhaseTransition.INTERMISSION_END] + ), + + # Wormhole + PhaseTransitionDefinition( + description='Wormhole formation begins', + game_id=11347, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 0}, + transition_types=[PhaseTransition.INTERMISSION_START] + ), + PhaseTransitionDefinition( + description='Wormhole formation ends', + game_id=11347, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 1}, + transition_types=[PhaseTransition.INTERMISSION_END] + ), + + # P4 transition + PhaseTransitionDefinition( + description='P3 ends, P4 transition begins', + game_id=11347, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 0}, + transition_types=[PhaseTransition.END, PhaseTransition.INTERMISSION_START] + ), + + # P4 + PhaseTransitionDefinition( + description='Transition ends, P4 begins', + game_id=11349, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 1}, + transition_types=[PhaseTransition.START, PhaseTransition.INTERMISSION_END] + ), + + # Fate alpha + PhaseTransitionDefinition( + description='Fate calibration alpha begins', + game_id=11349, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 0}, + transition_types=[PhaseTransition.INTERMISSION_START] + ), + PhaseTransitionDefinition( + description='Fate calibration alpha ends', + game_id=11349, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 1}, + transition_types=[PhaseTransition.INTERMISSION_END] + ), + + # Fate beta + PhaseTransitionDefinition( + description='Fate calibration beta begins', + game_id=11349, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 0}, + transition_types=[PhaseTransition.INTERMISSION_START] + ), + PhaseTransitionDefinition( + description='Fate calibration beta ends', + game_id=11349, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 1}, + transition_types=[PhaseTransition.INTERMISSION_END] + ), + ) diff --git a/fflogsapi/data/phases/omega.py b/fflogsapi/data/phases/omega.py new file mode 100644 index 0000000..c7461e2 --- /dev/null +++ b/fflogsapi/data/phases/omega.py @@ -0,0 +1,165 @@ +from ...constants import EventType +from .phases import FightPhaseData, PhaseTransition, PhaseTransitionDefinition +from dataclasses import dataclass + + +@dataclass +class OmegaPhaseData(FightPhaseData): + encounter_id: int = 1068 + + phases: tuple[str] = ( + 'Omega', + 'Omega M/F', + 'Omega Reconfigured', + 'Blue Screen', + 'Run: Dynamis', + 'Alpha Omega', + ) + + intermissions: tuple[str] = ( + 'Party Synergy', + 'P3 Transition', + 'P5 Transition', + 'Run: ****mi* (Delta)', + 'Run: ****mi* (Sigma)', + 'Run: ****mi* (Omega)', + 'P6 Transition', + ) + + phase_definitions: tuple[tuple[int, int, list[PhaseTransition]]] = ( + # P2 + PhaseTransitionDefinition( + description='P1 ends and P2 starts', + game_id=15712, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 1}, + transition_types=[PhaseTransition.END, PhaseTransition.START] + ), + PhaseTransitionDefinition( + description='P2 - Party synergy mechanic begins', + game_id=15712, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 0}, + transition_types=[PhaseTransition.INTERMISSION_START] + ), + PhaseTransitionDefinition( + description='P2 - Party synergy mechanic ends', + game_id=15712, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 1}, + transition_types=[PhaseTransition.INTERMISSION_END] + ), + + # P3 + PhaseTransitionDefinition( + description='P2 ends, P3 starts with the transition mechanic', + game_id=15712, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 0}, + transition_types=[ + PhaseTransition.END, + PhaseTransition.START, + PhaseTransition.INTERMISSION_START, + ] + ), + PhaseTransitionDefinition( + description='P3 transition ends, Omega becomes targetable', + game_id=15717, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 1}, + transition_types=[PhaseTransition.INTERMISSION_END] + ), + + # Could add an intermission between P3-P4 but idk if there's any point as there's no damage + + # P4 + PhaseTransitionDefinition( + description='P3 ends and P4 begins as Omega becomes targetable again', + game_id=15717, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 1}, + transition_types=[PhaseTransition.END, PhaseTransition.START] + ), + + # P5 + PhaseTransitionDefinition( + description='P5 transition starts', + game_id=15717, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 0}, + transition_types=[PhaseTransition.INTERMISSION_START] + ), + + PhaseTransitionDefinition( + description='P4 & P5 transition ends, P5 starts', + game_id=15720, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 1}, + transition_types=[ + PhaseTransition.END, + PhaseTransition.INTERMISSION_END, + PhaseTransition.START, + ] + ), + + # Dynamis delta + PhaseTransitionDefinition( + description='P5 - Run: ****mi* (Delta) begins', + game_id=15720, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 0}, + transition_types=[PhaseTransition.INTERMISSION_START] + ), + PhaseTransitionDefinition( + description='P5 - Run: ****mi* (Delta) ends', + game_id=15720, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 1}, + transition_types=[PhaseTransition.INTERMISSION_END] + ), + + # Dynamis sigma + PhaseTransitionDefinition( + description='P5 - Run: ****mi* (Sigma) begins', + game_id=15720, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 0}, + transition_types=[PhaseTransition.INTERMISSION_START] + ), + PhaseTransitionDefinition( + description='P5 - Run: ****mi* (Sigma) ends', + game_id=15720, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 1}, + transition_types=[PhaseTransition.INTERMISSION_END] + ), + + # Dynamis omega + PhaseTransitionDefinition( + description='P5 - Run: ****mi* (Omega) begins', + game_id=15720, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 0}, + transition_types=[PhaseTransition.INTERMISSION_START] + ), + PhaseTransitionDefinition( + description='P5 - Run: ****mi* (Omega) ends', + game_id=15720, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 1}, + transition_types=[PhaseTransition.INTERMISSION_END] + ), + + # P6 + PhaseTransitionDefinition( + description='P5 ends and P6 transition begins', + game_id=15720, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 0}, + transition_types=[ + PhaseTransition.END, + PhaseTransition.INTERMISSION_START, + ] + ), + + PhaseTransitionDefinition( + description='P6 begins with Omega-F casting Blind Faith', + game_id=2000021, + event_def={'type': EventType.CAST.value, 'abilityGameID': 32626}, + transition_types=[ + PhaseTransition.START, + ] + ), + + PhaseTransitionDefinition( + description='P6 transition ends', + game_id=15725, + event_def={'type': EventType.TARGETABILITY_UPDATE.value, 'targetable': 1}, + transition_types=[PhaseTransition.INTERMISSION_END] + ), + ) diff --git a/fflogsapi/data/phases/phases.py b/fflogsapi/data/phases/phases.py new file mode 100644 index 0000000..bdf9b3b --- /dev/null +++ b/fflogsapi/data/phases/phases.py @@ -0,0 +1,198 @@ +''' +Compute information about phased fights (like ultimates) that is not exposed by the FF Logs API +''' + +from typing import TYPE_CHECKING +from dataclasses import dataclass +from enum import Enum + +from ...util.gql_enums import GQLEnum +from queue import Queue + +if TYPE_CHECKING: + from ...reports import FFLogsFight + + +class PhaseType(Enum): + PHASE = 'phase' + '''Combat phase''' + INTERMISSION = 'intermission' + '''Intermission''' + + +class PhaseTransition(Enum): + START = 'start' + END = 'end' + INTERMISSION_START = 'intermission start' + INTERMISSION_END = 'intermission end' + + +@dataclass +class PhaseInformation: + '''Name, timestamps and type of a fight phase''' + + type: PhaseType + '''The type of phase. Either PhaseType.PHASE or PhaseType.INTERMISSION''' + name: str = 'N/A' + '''Pretty name of the phase or intermission''' + start: int = -1 + '''Millisecond precision timestamp of when the phase starts, relative to the report start''' + end: int = -1 + '''Millisecond precision timestamp of when the phase ends, relative to the report start''' + + +@dataclass +class PhaseTransitionDefinition: + '''A set of information that determines when and how phase transitions occur''' + + description: str + ''' + Human-readable description of what kind of transition this is. + Mostly just to keep large lists of :class:`PhaseTransitionDefinition`s readable. + ''' + + game_id: int + '''Game ID of the actor/boss that is the source of the phase defining event''' + + event_def: dict + ''' + A dictionary that, at the bare minimum, contains a "type" field to filter event type with. + Any other fields are used to determine which event defines the transition + ''' + + transition_types: list[PhaseTransition] + '''A list of phase transition types that occur when the event defined by ``event_def`` occurs''' + + +@dataclass +class FightPhaseData: + encounter_id: int + '''The encounter which this phase data applies for''' + + phases: tuple[str] + '''Ordered names of all phases''' + intermissions: tuple[str] + '''Ordered names of all intermissions''' + + phase_definitions: tuple[PhaseTransitionDefinition] + '''An ordered list of information to define the start and end of different phases.''' + + def total_phases(self) -> int: + ''' + Returns: + The total amount of phases, including intermissions + ''' + return len(self.phases) + len(self.intermissions) + + def get_phases(self, fight: 'FFLogsFight') -> list[PhaseInformation]: + ''' + Iteratively searches for the next phase defining event in the fight's event log, + creating new phase information in each iteration to construct a list of information + on every phase in the fight. + + Args: + fight: The :class:`FFLogsFight` to extract phase information from + Returns: + A list of :class:`PhaseInformation`s with information on all the different phases + in the fight. + ''' + fight_start = fight.start_time() + fight_end = fight.end_time() + game_id_map = {actor.id: actor.game_id for actor in fight.report.actors()} + + phases = [] + + phase_idx, intermission_idx = 0, 0 + phase_start, phase_end = fight_start, 0 + intermission_start, intermission_end = 0, 0 + + def_queue = Queue() + for definition in self.phase_definitions: + def_queue.put(definition) + phase_def: PhaseTransitionDefinition = def_queue.get() + + filter_types = [] + for definition in self.phase_definitions: + filter_types.append(definition.event_def['type']) + filter_types = set(filter_types) + + done = False + while not done: + # refetching events and parsing the event log iteratively as we progress + # is much much faster than fetching the full event log and then parsing + events = fight.events(filters={ + 'filterExpression': f'type = "{phase_def.event_def["type"]}"', + 'hostilityType': GQLEnum('Enemies'), + 'startTime': max(phase_start, intermission_start), + }) + + # trawl through all events leading up to the next phase transition event we expect + done = True + for event in events: + game_id = game_id_map[event['sourceID']] + + # check if the event's boss gameID matches + if game_id != phase_def.game_id: + continue + # then check if the rest of the event matches by checking + # if the event definition is a subset of the event + if not (phase_def.event_def.items() <= event.items()): + continue + + # check what types of phase transitions the event corresponds to + # phases are recorded when they end + timestamp = event['timestamp'] + for transition in phase_def.transition_types: + if transition == PhaseTransition.START: + phase_start = timestamp + elif transition == PhaseTransition.INTERMISSION_START: + intermission_start = timestamp + elif transition == PhaseTransition.END: + phase_end = timestamp + phases.append(PhaseInformation( + type=PhaseType.PHASE, + name=self.phases[phase_idx], + start=phase_start, end=phase_end, + )) + phase_idx += 1 + elif transition == PhaseTransition.INTERMISSION_END: + intermission_end = timestamp + phases.append(PhaseInformation( + type=PhaseType.INTERMISSION, + name=self.intermissions[intermission_idx], + start=intermission_start, end=intermission_end, + )) + intermission_idx += 1 + + # finished processing the last phase defining event + if def_queue.empty(): + break + + # move on to the next phase defining event + old_event_type = phase_def.event_def['type'] + phase_def = def_queue.get() + # if the next phase defining event type is different, + # break out so we can retrieve new events + if phase_def.event_def['type'] != old_event_type: + done = False + break + + # manually insert phases if we were in the middle of one when the fight ended + if phase_start >= phase_end and (phase_start + phase_end) != 0: + phases.append(PhaseInformation( + type=PhaseType.PHASE, + name=self.phases[phase_idx], + start=phase_start, end=fight_end, + )) + + if intermission_start >= intermission_end and (intermission_start + intermission_end) != 0: + phases.append(PhaseInformation( + type=PhaseType.INTERMISSION, + name=self.intermissions[intermission_idx], + start=intermission_start, end=fight_end, + )) + + # sort the list by start time + # if a phase and intermission start at the same time, the phase start gets precedence + phases.sort(key=lambda phase: phase.start + (0 if phase.type == PhaseType.PHASE else 1)) + return phases diff --git a/fflogsapi/reports/fight.py b/fflogsapi/reports/fight.py index 45b79f8..3154af3 100644 --- a/fflogsapi/reports/fight.py +++ b/fflogsapi/reports/fight.py @@ -2,7 +2,7 @@ from ..characters.character import FFLogsCharacter from ..data import (FFGameZone, FFLogsNPCData, FFLogsPlayerDetails, FFLogsReportCharacterRanking, - FFLogsReportComboRanking, FFLogsReportRanking, FFMap,) + FFLogsReportComboRanking, FFLogsReportRanking, FFMap, ALL_PHASE_DATA) from ..util.decorators import fetch_data from ..util.filters import construct_filter_string from ..util.indexing import itindex @@ -10,6 +10,7 @@ from .queries import Q_FIGHT_DATA if TYPE_CHECKING: + from ..data import PhaseInformation from ..client import FFLogsClient from .report import FFLogsReport @@ -187,6 +188,36 @@ def duration(self) -> float: ''' return self.end_time() - self.start_time() + @fetch_data('encounterID') + def phases(self) -> Optional[list['PhaseInformation']]: + ''' + WARNING: VERY SLOW! + + This is an addon functionality that is not present in the FF Logs API. It's reliant + on a custom phase tracking implementation that crawls the event log. + The resulting phase information may not line up exactly with what you see + on the FF Logs website. If you get ``None`` even when you know the fight has + multiple phases, phase data might simply not have been implemented. + + Some extra data may be provided, and names may not match up when compared to FF Logs. + Use some judgement when you use this data. + + Returns: + A list of information on each phase of the fight, or ``None`` if no phase data + was associated with the encounter. + ''' + phase_data = None + for data in ALL_PHASE_DATA: + if data.encounter_id == self._data['encounterID']: + phase_data = data + break + + # this fight does not have phase data associated with it + if not phase_data: + return None + + return phase_data.get_phases(self) + @fetch_data('completeRaid') def complete_raid(self) -> bool: ''' @@ -233,7 +264,7 @@ def _prepare_data_filters(self, filters: dict[str, Any]) -> tuple[str, dict[str, return construct_filter_string(filters), filters - def events(self, filters: dict[str, Any] = {}) -> dict[Any, Any]: + def events(self, filters: dict[str, Any] = {}) -> list[dict[str, Any]]: ''' Retrieves the events of the fight. @@ -247,7 +278,7 @@ def events(self, filters: dict[str, Any] = {}) -> dict[Any, Any]: Args: filters: Filters to use when retrieving event log data. Returns: - A dictionary of all events in the fight or None if the fight has zero duration + A filtered list of all events in the fight or None if the fight has zero duration ''' if self.duration() == 0: return None @@ -264,9 +295,7 @@ def events(self, filters: dict[str, Any] = {}) -> dict[Any, Any]: # If so, retrieve all of them and merge the data. next_page = result['events']['nextPageTimestamp'] while next_page and next_page < desired_end: - time_range = filters['endTime'] - filters['startTime'] filters['startTime'] = next_page - filters['endTime'] = min(next_page + time_range, desired_end) filter_string = construct_filter_string(filters) result = self.report._query_data( diff --git a/tests/reports/test_phases.py b/tests/reports/test_phases.py new file mode 100644 index 0000000..5dae3a1 --- /dev/null +++ b/tests/reports/test_phases.py @@ -0,0 +1,199 @@ +import unittest + +from fflogsapi import FFLogsClient +from fflogsapi.data import PhaseType, OmegaPhaseData, AlexanderPhaseData + +from ..config import CACHE_EXPIRY, CLIENT_ID, CLIENT_SECRET + + +class FightTest(unittest.TestCase): + ''' + Test cases for fight phases. + + This test case makes assumptions on the availability of specific reports. + If the tests break, it may be because visibility settings + were changed or the reports were deleted. + ''' + + # report codes and select fight ids: kill, somewhere in the middle, p1 and last phase wipe + TOP_REPORT = ('ZLHv7rfd1RAQMWaV', 20, 1, 17, 4) + TEA_REPORT = ('xdkpfmtDGMzrH6Ka', 11, 4, 7, 8) + + @classmethod + def setUpClass(cls) -> None: + cls.client = FFLogsClient(CLIENT_ID, CLIENT_SECRET, cache_expiry=CACHE_EXPIRY) + + @classmethod + def tearDownClass(cls) -> None: + cls.client.close() + cls.client.save_cache() + + def test_top(self) -> None: + ''' + The client should be able to extract phase information from TOP fights + ''' + # All phases and intermissions in order + all_phases = ( + 'Omega', + 'Omega M/F', + 'Party Synergy', + 'Omega Reconfigured', + 'P3 Transition', + 'Blue Screen', + 'P5 Transition', + 'Run: Dynamis', + 'Run: ****mi* (Delta)', + 'Run: ****mi* (Sigma)', + 'Run: ****mi* (Omega)', + 'P6 Transition', + 'Alpha Omega', + ) + + top_report = self.client.get_report(self.TOP_REPORT[0]) + + # Test kill + top_fight = top_report.fight(self.TOP_REPORT[1]) + phases = top_fight.phases() + # 6 phases and 7 intermissions in total + self.assertEqual(len(phases), OmegaPhaseData.total_phases()) + self.assertEqual(phases[0].start, top_fight.start_time()) + self.assertEqual(phases[-1].end, top_fight.end_time()) + + # make sure all phases are accounted for and in correct order + phase_names = tuple(map(lambda p: p.name, phases)) + self.assertTupleEqual(phase_names, all_phases) + + # Test wipe on P4 (somewhere in the middle) + top_fight = top_report.fight(self.TOP_REPORT[2]) + phases = top_fight.phases() + self.assertEqual(len(phases), 6) + self.assertEqual(phases[0].start, top_fight.start_time()) + self.assertEqual(phases[-1].end, top_fight.end_time()) + + phase_names = tuple(map(lambda p: p.name, phases)) + self.assertTupleEqual( + phase_names, + ( + 'Omega', + 'Omega M/F', + 'Party Synergy', + 'Omega Reconfigured', + 'P3 Transition', + 'Blue Screen', + ) + ) + + # Test a phase 1 wipe + top_fight = top_report.fight(self.TOP_REPORT[3]) + phases = top_fight.phases() + self.assertEqual(len(phases), 1) + self.assertEqual(phases[0].start, top_fight.start_time()) + self.assertEqual(phases[-1].end, top_fight.end_time()) + + phase_names = tuple(map(lambda p: p.name, phases)) + self.assertTupleEqual(phase_names, ('Omega',)) + + # Test a phase 6 wipe + top_fight = top_report.fight(self.TOP_REPORT[4]) + phases = top_fight.phases() + self.assertEqual(len(phases), OmegaPhaseData.total_phases()) + self.assertEqual(phases[0].start, top_fight.start_time()) + self.assertEqual(phases[-1].end, top_fight.end_time()) + + phase_names = tuple(map(lambda p: p.name, phases)) + self.assertTupleEqual(phase_names, all_phases) + + def test_tea(self) -> None: + ''' + The client should be able to extract phase information from TEA fights + ''' + all_phases = ( + 'Living Liquid', + 'Limit Cut', + 'Brute Justice and Cruise Chaser', + 'Temporal Stasis', + 'Alexander Prime', + 'Inception Formation', + 'Wormhole Formation', + 'P4 Transition', + 'Perfect Alexander', + 'Fate Calibration Alpha', + 'Fate Calibration Beta', + ) + + tea_report = self.client.get_report(self.TEA_REPORT[0]) + + # Test kill + tea_fight = tea_report.fight(self.TEA_REPORT[1]) + phases = tea_fight.phases() + self.assertEqual(len(phases), AlexanderPhaseData.total_phases()) + self.assertEqual(phases[0].start, tea_fight.start_time()) + self.assertEqual( + list(filter(lambda p: p.type == PhaseType.PHASE, phases))[-1].end, + tea_fight.end_time() + ) + + # make sure all phases are accounted for and in correct order + phase_names = tuple(map(lambda p: p.name, phases)) + self.assertTupleEqual(phase_names, all_phases) + + # Test wipe on temporal stasis + tea_fight = tea_report.fight(self.TEA_REPORT[2]) + phases = tea_fight.phases() + self.assertEqual(len(phases), 4) + self.assertEqual(phases[0].start, tea_fight.start_time()) + self.assertEqual(phases[-1].end, tea_fight.end_time()) + + phase_names = tuple(map(lambda p: p.name, phases)) + self.assertTupleEqual( + phase_names, + ( + 'Living Liquid', + 'Limit Cut', + 'Brute Justice and Cruise Chaser', + 'Temporal Stasis', + ) + ) + + # Test a phase 1 wipe + tea_fight = tea_report.fight(self.TEA_REPORT[3]) + phases = tea_fight.phases() + self.assertEqual(len(phases), 1) + self.assertEqual(phases[0].start, tea_fight.start_time()) + self.assertEqual( + list(filter(lambda p: p.type == PhaseType.PHASE, phases))[-1].end, + tea_fight.end_time() + ) + + phase_names = tuple(map(lambda p: p.name, phases)) + self.assertTupleEqual(phase_names, ('Living Liquid',)) + + # Test a phase 4 wipe just before fate alpha + tea_fight = tea_report.fight(self.TEA_REPORT[4]) + phases = tea_fight.phases() + self.assertEqual(len(phases), 9) + self.assertEqual(phases[0].start, tea_fight.start_time()) + self.assertEqual( + list(filter(lambda p: p.type == PhaseType.PHASE, phases))[-1].end, + tea_fight.end_time() + ) + + phase_names = tuple(map(lambda p: p.name, phases)) + self.assertTupleEqual( + phase_names, + ( + 'Living Liquid', + 'Limit Cut', + 'Brute Justice and Cruise Chaser', + 'Temporal Stasis', + 'Alexander Prime', + 'Inception Formation', + 'Wormhole Formation', + 'P4 Transition', + 'Perfect Alexander', + ) + ) + + +if __name__ == '__main__': + unittest.main()