Skip to content

Commit

Permalink
Add incomplete/absurd log detection.
Browse files Browse the repository at this point in the history
• For runs that have a proper kill sequence, it is now checked whether the run is valid before it is converted to a run with relative timings. If it is invalid, the reasons are reported back to the user and the program gracefully continues with the next run.
• Add documentation to several methods.
• Update README.md to include the new feature.
• Version bump to 2.5.1
  • Loading branch information
Iterniam committed Dec 3, 2021
1 parent dbc318a commit b0b54c7
Show file tree
Hide file tree
Showing 5 changed files with 106 additions and 12 deletions.
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Profit-Taker Analyzer
**Approved tool by Warframe Speedrun!
**Approved tool by the Warframe Speedrunning community!
https://www.speedrun.com/wf/resources**

This tool analyzes Profit-Taker runs from based on Warframe's log file, EE.log.
Expand All @@ -22,14 +22,15 @@ Linux users will have to export the folder that contains /Warframe/EE.log as LOC
2. Follows the game's log file analyze your runs live (survives game restarts!)
3. Displays the first shield element as soon as Profit-Taker spawns in follow mode.
4. Marks the best run and displays timestamps and phase durations.
5. Detects the [leg regen bug](https://forums.warframe.com/topic/1228077-reliable-repro-cause-known-profit-taker-leg-regen-recovering-from-the-pylon-phase-fully-heals-its-legs-5-seconds-after-theyve-already-been-vulnerable/?tab=comments#comment-11997156) and marks the extra legs in red.
6. Supports multiple Profit-Taker runs per EE.log
7. Automatically checks for newer versions.
5. Supports multiple Profit-Taker runs per EE.log
6. Detects the [leg regen bug](https://forums.warframe.com/topic/1228077-reliable-repro-cause-known-profit-taker-leg-regen-recovering-from-the-pylon-phase-fully-heals-its-legs-5-seconds-after-theyve-already-been-vulnerable/?tab=comments#comment-11997156) and marks the extra legs in red.
7. Detects bugs that lead to incomplete logs and indicates what information is missing.
8. Automatically checks for newer versions.

**Limitations:**
1. The tool can only detect runs where you are the host.
2. The tool can only detect shield changes, not the cause of it. This means it cannot differentiate between it being destroyed and it getting reset by an Amp or the time limit.
3. The log shows when the shields change, but not when they are broken. The only way to know when the shield element changes, is to analyze when the next shield is put up. For the last shield in a shield phase this cannot be done, so the time is shown as **?s**. The time of the final shield is added to the first leg break of the subsequent armor phase.
4. The tool won't detect runs that are affected by the [pylon stacking bug](https://forums.warframe.com/topic/1272496-profit-taker-pylons-landing-on-top-of-each-other-prevent-the-bounty-from-completing/) or other bugs that mess with the logs.
4. The tool won't show stats runs that are affected by the [pylon stacking bug](https://forums.warframe.com/topic/1272496-profit-taker-pylons-landing-on-top-of-each-other-prevent-the-bounty-from-completing/) or other bugs that mess with the logs.

Feel free to contact me on Discord about this tool: **Iterniam#5829**
2 changes: 1 addition & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from src.analyzer import Analyzer
from src.utils import color

VERSION = 'v2.5'
VERSION = 'v2.5.1'


def check_version():
Expand Down
90 changes: 84 additions & 6 deletions src/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from sty import rs, fg

from src.enums.damage_types import DT
from src.exceptions.bugged_run import BuggedRun
from src.exceptions.log_end import LogEnd
from src.exceptions.run_abort import RunAbort
from src.utils import color, time_str, oxfordcomma
Expand Down Expand Up @@ -171,14 +172,78 @@ def __init__(self, run_nr: int):
def __str__(self):
return '\n'.join((f'{key}: {val}' for key, val in vars(self).items()))

def post_process(self):
def post_process(self) -> None:
"""
Reorders some timing information to be more consistent with what we expect rather than what we get
Throws `BuggedRun` if no shield elements were recorded for the final shield phase.
"""
# Take the final shield from shield phase 3.5 and prepend it to phase 4.
if len(self.shield_phases[3.5]) > 0: # If the player is too fast, there won't be phase 3.5 shields.
self.shield_phases[4] = [self.shield_phases[3.5].pop()] + self.shield_phases[4]

# Remove the extra shield from phase 4.
self.shield_phases[4].pop()
try:
self.shield_phases[4].pop()
except IndexError:
raise BuggedRun('No shields were recorded in phase 4.') from None

def check_run_integrity(self) -> None:
"""
Checks whether all required information is present to convert the run into a run with relative timings.
If not all information is present, this method throws BuggedRun with the failure reasons.
"""
failure_reasons = []
for phase in [1, 2, 3, 4]:
# Shield phases (phase 1, 3, 4) have at least 3 shields per phase.
# The default is 5 shields, but because shots damage is capped to the shield element phase's max HP
# instead of the remaining phase's max HP, a minimum of 3 elements per shield phase can be achieved
if phase in [1, 3, 4] and len(self.shield_phases[phase]) < 3:
failure_reasons.append(f'{len(self.shield_phases[phase])} shield elements were recorded in phase '
f'{phase} but at least 3 shield elements were expected.')

# Every phase has an armor phase, and every armor phase needs at least 4 legs to be taken down
# When less than 4 legs are recorded to be taken out, obviously there's a bug
if len(self.legs[phase]) < 4:
failure_reasons.append(f'{len(self.legs[phase])} legs were recorded in phase {phase} but at least 4 '
f'legs were expected.')

# It is intended for 4 legs to be taken out. Because of the leg regen bug, up to 8 legs can be taken out
# If somehow more than 8 legs are taken out per phase, that signifies an even worse bug
# Since 'even worse bugs' tend to corrupt the logs, so we print a warning to the user
# This tool should still be able to convert and display it, so it doesn't fail the integrity check
if len(self.legs[phase]) > 8:
print(color(f'{len(self.legs[phase])} leg kills were recorded for phase {phase}.\n'
f'If you have a recording of this run and the fight indeed bugged out, please '
f'report the bug to Warframe.\n'
f'If you think the bug is with the analyzer, contact the creator of this tool instead.',
fg.li_red))

# The time at which the body becomes vulnerable and is killed during the armor phase has to be present
if phase not in self.body_vuln:
failure_reasons.append(f'Profit-Taker\'s body was not recorded as being vulnerable in phase {phase}.')
if phase not in self.body_kill:
failure_reasons.append(f'Profit-Taker\'s body was not recorded as being killed in phase {phase}.')

# If in the pylon phases (phase 1 and 3) the pylon start- or end time are not recorded, then the
# logs (and probably fight) are bugged. The run cannot be converted.
if phase in [1, 3]:
if phase not in self.pylon_start:
failure_reasons.append(f'No pylon phase start time was recorded in phase {phase}.')
if phase not in self.pylon_end:
failure_reasons.append(f'No pylon phase end time was recorded in phase {phase}.')

if failure_reasons:
raise BuggedRun(failure_reasons)
# Else: return none implicitly

def to_rel(self) -> RelRun:
"""
Converts this AbsRun with absolute timings to RelRun with relative timings.
If not all information is present, a `BuggedRun` exception is thrown.
"""
self.check_run_integrity()

pt_found = self.pt_found - self.heist_start
phase_durations = {}
shield_phases = defaultdict(list)
Expand All @@ -189,11 +254,13 @@ def to_rel(self) -> RelRun:
previous_value = self.pt_found
for phase in [1, 2, 3, 4]:
if phase in [1, 3, 4]: # Phases with shield phases
# Register the times and elements for the shields
for i in range(len(self.shield_phases[phase]) - 1):
shield_type, _ = self.shield_phases[phase][i]
_, shield_end = self.shield_phases[phase][i + 1]
shield_phases[phase].append((shield_type, shield_end - previous_value))
previous_value = shield_end
# The final shield of each phase doesn't have a time
shield_phases[phase].append((self.shield_phases[phase][-1][0], nan))
# Every phase has an armor phase
for leg in self.legs[phase]:
Expand All @@ -209,7 +276,7 @@ def to_rel(self) -> RelRun:
# Set phase duration
phase_durations[phase] = previous_value - self.heist_start

# Set phase 3.5 shields
# Set phase 3.5 shields (possibly none on very fast runs)
shield_phases[3.5] = [(shield, nan) for shield, _ in self.shield_phases[3.5]]

return RelRun(self.run_nr, self.nickname, self.squad_members, pt_found,
Expand Down Expand Up @@ -281,6 +348,9 @@ def analyze_log(self, dropped_file: str):
require_heist_start = True
except RunAbort as abort:
require_heist_start = abort.require_heist_start
except BuggedRun as buggedRun:
print(color(str(buggedRun), fg.li_red)) # Prints reasons why the run failed
require_heist_start = True
except LogEnd:
pass
if len(self.runs) > 0:
Expand All @@ -291,7 +361,7 @@ def analyze_log(self, dropped_file: str):

self.print_summary()
else:
print(f'{fg.white}No Profit-Taker runs found.\n'
print(f'{fg.white}No valid Profit-Taker runs found.\n'
f'Note that you have to be host throughout the entire run for it to show up as a valid run.')

print(f'{rs.fg}Press ENTER to exit...')
Expand All @@ -314,6 +384,9 @@ def follow_log(self, filename: str):
self.print_summary()
except RunAbort as abort:
require_heist_start = abort.require_heist_start
except BuggedRun as buggedRun:
print(color(str(buggedRun), fg.li_red)) # Prints reasons why the run failed
require_heist_start = True

def read_run(self, log: Iterator[str], run_nr: int, require_heist_start=False) -> AbsRun:
"""
Expand All @@ -322,6 +395,8 @@ def read_run(self, log: Iterator[str], run_nr: int, require_heist_start=False) -
:param run_nr: The number assigned to this run if it does not end up being aborted.
:param require_heist_start: Indicate whether the start of this run indicates a previous run that was aborted.
Necessary to properly initialize this run.
:raise RunAbort: The run was aborted, had a bugged kill sequence, or restarted before it was completed.
:raise BuggedRun: The run was completed but has missing information.
:return: Absolute timings from the fight.
"""
# Find heist load.
Expand All @@ -332,11 +407,14 @@ def read_run(self, log: Iterator[str], run_nr: int, require_heist_start=False) -

for phase in [1, 2, 3, 4]:
self.register_phase(log, run, phase) # Adds information to run, including the start time
run.post_process() # Apply shield phase corrections
run.post_process() # Apply shield phase corrections & check for run integrity

return run

def register_phase(self, log: Iterator[str], run: AbsRun, phase: int):
def register_phase(self, log: Iterator[str], run: AbsRun, phase: int) -> None:
"""
Registers information to `self` for the current phase based on the information found in the logs.
"""
kill_sequence = 0
while True: # match exists for phases 1-3, kill_sequence for phase 4.
try:
Expand Down
12 changes: 12 additions & 0 deletions src/exceptions/bugged_run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class BuggedRun(RuntimeError):
"""An exception indicating that a run has bugged out - it does not have
enough information to convert to a relative run.
If require_heist_start is set to True, the analyzer should look for a 'job start' line.
Otherwise, the analyzer can assume that a new run started that aborted the old run."""
def __init__(self, reasons: list[str]):
self.reasons = reasons

def __str__(self):
reason_str = '\n'.join(self.reasons)
return f'Bugged run detected, no stats will be displayed. Bugs found:\n{reason_str}\n'
3 changes: 3 additions & 0 deletions src/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from math import isnan
from typing import Iterable, Literal

from sty import rs
Expand All @@ -19,6 +20,8 @@ def oxfordcomma(collection: Iterable[str]):


def time_str(seconds: float, format_: Literal['brackets', 'units']) -> str:
if isnan(seconds):
return 'nan'
if format_ == 'brackets':
return f'[{int(seconds / 60)}:{int(seconds % 60):02d}]'
elif format_ == 'units':
Expand Down

0 comments on commit b0b54c7

Please sign in to comment.