Source code for src.gamedata

from __future__ import annotations

from typing import Any, Generator, Iterable, NamedTuple, Optional, TYPE_CHECKING

import urllib.parse
import collections
import datetime
import math
import io

from abc import ABC, abstractmethod

from aiohttp.web import Request, Response, HTTPForbidden, HTTPNotImplemented, HTTPNotFound

from src.typehints import *
from src.utils import format_for_slaytabase

try:
    from matplotlib import pyplot as plt
except ModuleNotFoundError:
    plt = None

try:
    from mpld3 import fig_to_html
except ModuleNotFoundError:
    fig_to_html = None

from src.nameinternal import get_event, get_relic_stats, get_run_mod, get, get_card, Card, SingleCard, Relic, Potion
from src.sts_profile import Profile
from src.logger import logger

from src.configuration import config

if TYPE_CHECKING:
    from src.runs import StreakInfo

__all__ = [
    "ShopContents",
    "KeysObtained",
    "RelicData",
    "CardData",
    "BottleRelic",

    "FileParser",

    "BaseNode",
    "NeowBonus",
    "NodeData",
    "EncounterBase",
    "NormalEncounter",
    "EventEncounter",
    "Treasure",
    "EventTreasure",
    "EliteEncounter",
    "EventElite",
    "EmptyEvent",
    "AmbiguousEvent",
    "Event",
    "EventFight",
    "Colosseum",
    "Merchant",
    "EventMerchant",
    "Courier",
    "Empty",
    "SWF",
    "Campfire",
    "Boss",
    "BossChest",
    "Act4Transition",
    "Victory",
]

# XXX Make sure to remove private member access from outside the class
# search for #PRIV# in this file for places that need modified
# put all of damage_taken, max_hp_{gained,lost} on the base class

[docs] class BaseNode(ABC): """This the common base class for all path nodes, including Neow. This provides basic utility functions that subclasses may use. This also has abstract methods and properties that need to be overridden. Subclasses can - and should - implement their own `get_description` method. This takes one argument, type `collections.defaultdict[int, list[str]]`, and should modify that mapping in-place. Returning any non-None value will raise an error. The method should **NOT** attempt to call the superclass version of it with `super()`. BaseNode's `description` method walks through the entire superclass tree, including third-party classes, in Method Resolution Order, and calls each existing `get_description` method in each class. Failure to implement this method in a direct subclass will raise an error, though additional classes in the MRO without one will pass silently. Built-in index keys all use even numbers. Result will be joined together with newlines, and returned in ascending order. Custom code should only add data to odd indices. Ranges are provided here; refer to the relevant subclass for details. Calling order between various methods is not guaranteed - appending to a key already used may have unexpected results. | 0 - Basic stuff; only for things that all nodes share | 2 - Event-specific combat setup | 4 - Combat results (damage taken, turns elapsed, etc.) | 6 - Unique values (key obtained, campfire option, etc.) | 8 - Neow bonus | 10-20 - Potion usage data | 30-36 - Relic obtention data | 40-62 - Event results | 70-78 - Shop contents | 100+ - Special stuff; reserved for bugged or modded content """ room_type: str = "" #: The display name of map nodes. end_of_act: bool = False #: Whether to add a newline after this map node. # the following are handled differently in each branch # as such, we only tell the type checkers what they are # if a subclass does not implement these, it is a bug current_hp: int #: The current HP when exiting the node. max_hp: int #: The max HP when exiting the node. gold: int #: The gold when exiting the node. floor: int #: The floor associated with this node. card_count: int #: How many cards we currently have. relic_count: int #: How many relics we currently have. potion_count: int #: How many potions we currently have. fights_count: int #: How many fights were fought so far this run. turns_count: int #: How many turns passed so far this run. floor_time: int #: How much time, in seconds, we spent on this floor. def __init__(self, parser: FileParser, *extra): """Generate a new BaseNode instance. :param parser: The run or save this node is in :type parser: FileParser """ super().__init__(*extra) self.parser = parser self.floor_time: int = 0 @property def potions(self) -> list[Potion]: """Potions obtained on this node.""" return self.parser.potions[self.floor] @property def picked(self) -> list[SingleCard]: """Cards picked this floor.""" ret = [] for card in self.parser.card_choices[0][self.floor]: ret.append(card) return ret @property def skipped(self) -> list[SingleCard]: """Cards skipped this floor.""" ret = [] for card in self.parser.card_choices[1][self.floor]: ret.append(card) return ret @property def cards_obtained(self) -> list[SingleCard]: """Cards obtained outside of a card reward this floor.""" return [] @property def cards_removed(self) -> list[SingleCard]: """Cards that were removed this floor.""" return [] @property def cards_transformed(self) -> list[SingleCard]: """Cards that were transformed away this floor.""" return [] @property def cards_upgraded(self) -> list[SingleCard]: """Cards that were upgraded this floor, before the upgrade.""" return [] @property def relics(self) -> list[Relic]: """Relics obtained on this floor.""" return self.parser.relics_obtained[self.floor] @property def relics_lost(self) -> list[Relic]: """Relics lost on this floor.""" return [] @property def used_potions(self) -> list[Potion]: """Potions used on this floor.""" return self.parser.potions_use[self.floor] @property def potions_from_alchemize(self) -> list[Potion]: """Potions obtained through Alchemize on this floor.""" return self.parser.potions_alchemize[self.floor] @property def potions_from_entropic(self) -> list[Potion]: """Potions obtained through Entropic Brew on this floor.""" return self.parser.potions_entropic[self.floor] @property def discarded_potions(self) -> list[Potion]: """Potions which were mercilessly discarded on this floor.""" return self.parser.potions_discarded[self.floor] @property def all_potions_received(self) -> list[Potion]: """All potions which were received on this floor.""" return self.potions + self.potions_from_alchemize + self.potions_from_entropic @property def all_potions_dropped(self) -> list[Potion]: """All potions which were used or discarded this floor.""" return self.used_potions + self.discarded_potions @property def skipped_relics(self) -> list[Relic]: """List of relics that were offered but skipped here.""" return self.parser.skipped_rewards[0][self.floor] @property def skipped_potions(self) -> list[Potion]: """List of potions that were offered but skipped here.""" return self.parser.skipped_rewards[1][self.floor] @property def name(self) -> str: """The name to display for the node.""" return ""
[docs] def card_delta(self) -> int: """How many cards were added or removed on this floor.""" return len(self.picked) + len(self.cards_obtained) - len(self.cards_removed) - len(self.cards_transformed)
[docs] def relic_delta(self) -> int: """How many relics were gained or lost on this floor.""" return len(self.relics) - len(self.relics_lost)
[docs] def potion_delta(self) -> int: """How many potions were obtained, used, or discarded on this floor.""" return len(self.all_potions_received) - len(self.all_potions_dropped)
[docs] def fights_delta(self) -> int: """How many fights were fought on this floor.""" return 0
[docs] def turns_delta(self) -> int: """How many turns were spent on this floor.""" return 0
[docs] def description(self) -> str: """Return a newline-separated description for the current node. Refer to BaseNode's docstring for implementation details.""" # TODO: Move away from numbers and use Enums to_append = collections.defaultdict(list) done = [] for cls in type(self).__mro__: try: fn = cls.get_description except AttributeError: continue # support multi-classing if you're Emily Axford if fn in done: continue if fn(self, to_append) is not None: # returned something raise RuntimeError(f"'{cls.__name__}.get_description()' returned a non-None value.") done.append(fn) if not to_append: continue try: if (n := min(to_append)) < 0: raise ValueError(f"Class {cls.__name__!r} used negative index {n} (positive values only)") except TypeError as e: raise TypeError(f"Class {cls.__name__!r} did not use a number-like type") from e final = [] desc = list(to_append.items()) desc.sort(key=lambda x: x[0]) for i, text in desc: final.extend(text) return "\n".join(final)
[docs] def escaped_description(self) -> str: """Return a description suitable for HTML pages.""" return self.description().replace("\n", "<br>").replace("'", "\\'")
# Every subclass must implement this method, and NOT call this one
[docs] def get_description(self, to_append: dict[int, list[str]]): """Add each individual node's description, as needed.""" if self.room_type: to_append[0].append(f"{self.room_type}") to_append[0].append(f"{self.current_hp}/{self.max_hp} - {self.gold} gold") if self.name: to_append[0].append(self.name)
[docs] class NeowBonus(BaseNode): all_bonuses = { "THREE_CARDS": "Choose one of three cards to obtain.", "RANDOM_COLORLESS": "Choose an uncommon Colorless card to obtain.", "RANDOM_COMMON_RELIC": "Obtain a random common relic.", "REMOVE_CARD": "Remove a card.", "TRANSFORM_CARD": "Transform a card.", "UPGRADE_CARD": "Upgrade a card.", "THREE_ENEMY_KILL": "Enemies in the first three combats have 1 HP.", "THREE_SMALL_POTIONS": "Gain 3 random potions.", "TEN_PERCENT_HP_BONUS": "Gain 10% Max HP.", "ONE_RANDOM_RARE_CARD": "Gain a random Rare card.", "HUNDRED_GOLD": "Gain 100 gold.", "TWO_FIFTY_GOLD": "Gain 250 gold.", "TWENTY_PERCENT_HP_BONUS": "Gain 20% Max HP.", "RANDOM_COLORLESS_2": "Choose a Rare colorless card to obtain.", "THREE_RARE_CARDS": "Choose a Rare card to obtain.", "REMOVE_TWO": "Remove two cards.", "TRANSFORM_TWO_CARDS": "Transform two cards.", "ONE_RARE_RELIC": "Obtain a random Rare relic.", "BOSS_RELIC": "Lose your starter relic. Obtain a random Boss relic.", # more Neow "CHOOSE_OTHER_CHAR_RANDOM_COMMON_CARD": "Choose a common card from another character to obtain.", "CHOOSE_OTHER_CHAR_RANDOM_UNCOMMON_CARD": "Choose an uncommon card from another character to obtain.", "CHOOSE_OTHER_CHAR_RANDOM_RARE_CARD": "Choose a rare card from another character to obtain.", "GAIN_RANDOM_SHOP_RELIC": "Obtain a random Shop relic.", "GAIN_POTION_SLOT": "Gain a potion slot.", "GAIN_TWO_POTION_SLOTS": "Gain two potions slots.", "GAIN_TWO_RANDOM_COMMON_RELICS": "Gain two random common relics.", "GAIN_UNCOMMON_RELIC": "Obtain a random uncommon relic.", "THREE_BIG_POTIONS": "Obtain three random potions.", "SWAP_MEMBERSHIP_COURIER": "Take a small loan... of a million gold.", } all_costs = { "CURSE": "Gain a curse.", "NO_GOLD": "Lose all gold.", "TEN_PERCENT_HP_LOSS": "Lose 10% Max HP.", "PERCENT_DAMAGE": "Take damage.", "TWO_STARTER_CARDS": "Add two starter cards.", "ONE_STARTER_CARD": "Add a starter card.", } floor = 0 @property def name(self) -> str: return "Neow bonus" @property def choice_made(self) -> bool: """Whether a Neow bonus was picked yet.""" return bool(self.parser._neow_picked[0]) @property def boon_picked(self) -> str: """Which Neow bonus was picked, in a human-readable format.""" bonus, cost = self.parser._neow_picked if cost == "NONE": return self.all_bonuses.get(bonus, bonus) return f"{self.all_costs.get(cost, cost)} {self.all_bonuses.get(bonus, bonus)}" @property def boons_skipped(self) -> Generator[str, None, None]: """Which Neow bonuses were skipped, in a human-readable format.""" data, bonuses, costs = self.parser._neow_data if not bonuses or not costs: yield "<Could not fetch data>" return for c, b in zip(costs, bonuses, strict=True): if c == "NONE": yield self.all_bonuses.get(b, b) else: yield f"{self.all_costs.get(c, c)} {self.all_bonuses.get(b, b)}" @property def cards_obtained(self) -> list[SingleCard]: ret = super().cards_obtained for card in self.parser._neow_data[0].get("cardsObtained", []): ret.append(get_card(card)) return ret @property def cards_removed(self) -> list[SingleCard]: ret = super().cards_removed for card in self.parser._neow_data[0].get("cardsRemoved", []): ret.append(get_card(card)) return ret @property def cards_transformed(self) -> list[SingleCard]: ret = super().cards_transformed for card in self.parser._neow_data[0].get("cardsTransformed", []): ret.append(get_card(card)) return ret @property def cards_upgraded(self) -> list[SingleCard]: ret = super().cards_upgraded for card in self.parser._neow_data[0].get("cardsUpgraded", []): ret.append(get_card(card)) return ret @property def relics(self) -> list[Relic]: ret = super().relics for x in self.parser._neow_data[0].get('relicsObtained', ()): ret.append(get(x)) return ret @property def damage_taken(self) -> int: """How much damage we took here.""" return self.parser._neow_data[0].get("damageTaken", 0) @property def max_hp_gained(self) -> int: """How much max HP we gained here.""" return self.parser._neow_data[0].get("maxHpGained", 0) @property def max_hp_lost(self) -> int: """How much max HP we lost here.""" return self.parser._neow_data[0].get("maxHpLost", 0) def _get_hp(self) -> tuple[int, int]: """Return how much HP the run had before entering floor 1 in a (current, max) tuple.""" if self.parser.character is None: return 0, 0 match self.parser.character: case "Snecko": base = 85 case "Ironclad" | "Guardian" | "Champ": base = 80 case "Defect" | "Hermit" | "Blade Gunner" | "Packmaster": base = 75 case "Watcher": base = 72 case "Silent" | "Bronze Automaton": base = 70 case "Hexaghost": base = 66 case "Slime Boss" | "Collector": base = 65 case "Gremlin Gang": base = 16 case a: base = 76 # idk man, seems fine? if self.parser.ascension_level >= 14: # lower max HP base -= math.floor(base/16) bonus = base // 10 cur = base if self.parser.ascension_level >= 6: # take damage cur -= math.ceil(cur / 10) if self.parser._neow_data[0]: cur -= self.damage_taken cur += self.max_hp_gained base -= self.max_hp_lost base += self.max_hp_gained return (cur, base) match self.parser._neow_picked[1]: case "TEN_PERCENT_HP_LOSS": base -= bonus if cur > base: cur = base case "PERCENT_DAMAGE": cur -= (cur // 10) * 3 match self.parser._neow_picked[0]: case "TEN_PERCENT_HP_BONUS": base += bonus cur += bonus case "TWENTY_PERCENT_HP_BONUS": base += (bonus * 2) cur += (bonus * 2) return (cur, base) @property def current_hp(self) -> int: return self._get_hp()[0] @property def max_hp(self) -> int: return self._get_hp()[1] @property def gold(self) -> int: base = 99 if (d := self.parser._neow_data[0]): base += (d["goldGained"] - d["goldLost"]) if "Old Coin" in d["relicsObtained"]: base += 300 return base if self.parser._neow_picked[1] == "NO_GOLD": base = 0 match self.parser._neow_picked[0]: case "HUNDRED_GOLD": base += 100 case "TWO_FIFTY_GOLD": base += 250 case "ONE_RARE_RELIC": if self.parser.relics_bare[0].name == "Old Coin": # this can break if N'loth is involved base += 300 return base
[docs] def get_cards(self) -> list[SingleCard]: """Return the cards we begin the run with.""" cards = [] if self.parser.ascension_level >= 10: cards.append("AscendersBane") match self.parser.character: case "Ironclad": cards.extend(("Strike_R",) * 5) cards.extend(("Defend_R",) * 4) cards.append("Bash") case "Silent": cards.extend(("Strike_G",) * 5) cards.extend(("Defend_G",) * 5) cards.append("Neutralize") cards.append("Survivor") case "Defect": cards.extend(("Strike_B",) * 4) cards.extend(("Defend_B",) * 4) cards.append("Zap") cards.append("Dualcast") case "Watcher": cards.extend(("Strike_P",) * 4) cards.extend(("Defend_P",) * 4) cards.append("Vigilance") cards.append("Eruption") case a: raise ValueError(f"I don't know how to handle {a!r}") cards = [get_card(x) for x in cards] for x in self.cards_transformed + self.cards_removed: cards.remove(x) for x in self.cards_obtained: cards.append(x) for x in self.cards_upgraded: index = cards.index(x) cards[index].upgrades += 1 cards.extend(self.picked) return cards
# options 1 & 2
[docs] def bonus_THREE_CARDS(self) -> str: """Call if the bonus is a draft of three cards, colorless or otherwise. :raises ValueError: If no matching card choice can be found :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ if self.picked: return f"picked {self.picked[0]} over {' and '.join(x.name for x in self.skipped)}" if self.skipped: return f"were offered {', '.join(x.name for x in self.skipped)} but skipped them all" raise ValueError("That is not the right bonus??")
bonus_RANDOM_COLORLESS = bonus_THREE_CARDS
[docs] def bonus_RANDOM_COMMON_RELIC(self) -> str: """Call if the bonus is a random common relic. :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ if self.relics: return f"got {self.relics[0]}" return "picked a random Common relic"
[docs] def bonus_REMOVE_CARD(self) -> str: """Call if the bonus is a card removal. :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ if self.cards_removed: return f"removed {self.cards_removed[0]}" return "removed a card"
[docs] def bonus_TRANSFORM_CARD(self) -> str: """Call if the bonus is a card transform. :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ if self.cards_transformed: return f"transformed {self.cards_transformed[0]} into {self.cards_obtained[0]}" return "transformed a card"
[docs] def bonus_UPGRADE_CARD(self) -> str: """Call if the bonus is a card upgrade. :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ if self.cards_upgraded: return f"upgraded {self.cards_upgraded[0]}" return "upgraded a card"
[docs] def bonus_THREE_ENEMY_KILL(self) -> str: """Call if the bonus is Neow's Lament. :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ return "got Neow's Lament to get three fights with enemies having 1 HP"
[docs] def bonus_THREE_SMALL_POTIONS(self) -> str: """Call if the bonus is three potions. :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ if self.skipped_potions: if not self.potions: return f"were offered {' and '.join(x.name for x in self.skipped_potions)} and skipped all of them... for some reason" return f"got {' and '.join(x.name for x in self.potions)}, and skipped {' and '.join(x.name for x in self.skipped_potions)}" return f"got {' and '.join(x.name for x in self.potions)}"
[docs] def bonus_TEN_PERCENT_HP_BONUS(self) -> str: """Call if the bonus is +10% Max HP. :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ if self.max_hp_gained: return f"gained {self.max_hp_gained} Max HP" return "gained 10% Max HP"
[docs] def bonus_ONE_RANDOM_RARE_CARD(self) -> str: """Call if the bonus is a random rare card. :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ if self.cards_obtained: return f"picked a random Rare card, and got {self.cards_obtained[0]}" return "picked a random Rare card"
# option 3
[docs] def bonus_TWO_FIFTY_GOLD(self) -> str: """Call if the bonus is 250 gold. :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ return "got 250 gold"
[docs] def bonus_TWENTY_PERCENT_HP_BONUS(self) -> str: """Call if the bonus is +20% Max HP. :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ if self.max_hp_gained: return f"gained {self.max_hp_gained} Max HP" return "gained 20% Max HP"
bonus_RANDOM_COLORLESS_2 = bonus_THREE_CARDS bonus_THREE_RARE_CARDS = bonus_THREE_CARDS
[docs] def bonus_REMOVE_TWO(self) -> str: """Call if the bonus is two card removals. :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ if self.cards_removed: return f"removed {' and '.join(x.name for x in self.cards_removed)}" return "removed two cards"
[docs] def bonus_TRANSFORM_TWO_CARDS(self) -> str: """Call if the bonus is two card transforms. :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ if self.cards_transformed: # in case the cost is "gain a curse", the curse will be the first item. this guarantees we only get the last two cards return f"transformed {' and '.join(x.name for x in self.cards_transformed)} into {' and '.join(x.name for x in self.cards_obtained[-2:])}" return "transformed two cards"
[docs] def bonus_ONE_RARE_RELIC(self) -> str: """Call if the bonus is a random rare relic. :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ if self.relics: return f"picked a random Rare relic and got {self.relics[0]}" return "obtained a random Rare relic"
# option 4
[docs] def bonus_BOSS_RELIC(self) -> str: """Call if the bonus is swap your starter relic for a random boss relic. :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ if self.relics: return f"swapped our starter relic for {self.relics[0]}" return f"swapped our starter relic for {self.parser.relics_bare[0]}" # N'loth can mess with this
# more Neow mod bonus_CHOOSE_OTHER_CHAR_RANDOM_COMMON_CARD = bonus_THREE_CARDS bonus_CHOOSE_OTHER_CHAR_RANDOM_UNCOMMON_CARD = bonus_THREE_CARDS bonus_CHOOSE_OTHER_CHAR_RANDOM_RARE_CARD = bonus_THREE_CARDS # costs for option 3
[docs] def cost_CURSE(self) -> str: """Call if the cost is gain a random curse. :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ if self.cards_obtained: return f"got cursed with {self.cards_obtained[0]}" return "got a random curse"
[docs] def cost_NO_GOLD(self) -> str: """Call if the cost is lose all gold. :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ return "lost all gold"
[docs] def cost_TEN_PERCENT_HP_LOSS(self) -> str: """Call if the cost is -10% Max HP. :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ if self.max_hp_lost: return f"lost {self.max_hp_lost} Max HP" return "lost 10% Max HP"
[docs] def cost_PERCENT_DAMAGE(self) -> str: """Call if the cost is ~30% current HP loss. :return: Human-readable string to be used by :meth:`as_str` :rtype: str """ if self.damage_taken: return f"took {self.damage_taken} damage" return "took damage (current HP / 10, rounded down, * 3)"
[docs] def get_description(self, to_append: dict[int, list[str]]): to_append[8].append(self.as_str())
[docs] def as_str(self) -> str: """Get the Neow bonus information. :return: Human-readable string of the cost and bonus. :rtype: str """ bonus, cost = self.parser._neow_picked neg = getattr(self, f"cost_{cost}", None) try: pos = getattr(self, f"bonus_{bonus}") except AttributeError: return "<No option picked/option unknown>" try: if neg is None: msg = f"We {pos()}." else: msg = f"We {neg()}, and then {pos()}." except ValueError: msg = "<Unknown option>" return msg
@property def has_info(self) -> bool: """Whether we support the bonus gotten.""" return hasattr(self, f"bonus_{self.parser._neow_picked[0]}") @property def cards(self) -> list[CardData]: """Cards we have, in an iterable format.""" try: l = self.get_cards() return [CardData(x, l) for x in l] except ValueError: return []
[docs] def card_delta(self) -> int: num = super().card_delta() + 10 if self.parser.character == "Silent": num += 2 if self.parser.ascension_level >= 10: num += 1 bonus, cost = self.parser._neow_picked if cost == "CURSE": num += 1 match bonus: case "REMOVE_CARD": num -= 1 case "REMOVE_TWO": num -= 2 case "ONE_RANDOM_RARE_CARD": num += 1 return num
[docs] def relic_delta(self) -> int: # does not handle calling bell num = 1 if self.parser._neow_picked[0] in ("THREE_ENEMY_KILL", "ONE_RARE_RELIC", "RANDOM_COMMON_RELIC"): num += 1 return num
@property def card_count(self) -> int: """How many cards we currently have.""" return self.card_delta() @property def relic_count(self) -> int: """How many relics we currently have.""" return self.relic_delta() @property def potion_count(self) -> int: """How many potions we currently have.""" return self.potion_delta() @property def fights_count(self) -> int: """How many fights were fought so far this run.""" return 0 @property def turns_count(self) -> int: """How many turns passed so far this run.""" return 0
_chars = { "SLIMEBOUND": "Slime Boss", "GREMLIN": "Gremlin Gang", "THE_SPIRIT": "Hexaghost", "THE_AUTOMATON": "Bronze Automaton", } _enemies = { # Downfall bosses "IC_STATUS_ARCHETYPE": "Status Ironclad", "SI_POISON_ARCHETYPE": "Poison Silent", "DF_ARCHETYPE_STREAMLINE": "0-cost Defect", "WA_ARCHETYPE_RETAIN": "Retain Watcher", "HERMIT_SHARPSHOOTER_ARCHETYPE": "Deadeye Hermit", "IC_MUSHROOM_ARCHETYPE": "Reaper Ironclad", "SI_MIRROR_ARCHETYPE": "After Image Silent", "DF_ARCHETYPE_CLAW": "Claw Defect", "WA_ARCHETYPE_CALM": "Blasphemy Watcher", "HERMIT_WHEEL_ARCHETYPE": "Rotating Cards Hermit", "IC_BLOCK_ARCHETYPE": "Barricade Ironclad", "SI_SHIV_ARCHETYPE": "Shiv Silent", "DF_ARCHETYPE_ORBS": "Orb Defect", "WA_ARCHETYPE_DIVINITY": "Mantra Watcher", "HERMIT_DOOMSDAY_ARCHETYPE": "Doomsday Hermit", "downfall:NeowBoss": "The heroes, Returned", "downfall:NeowBossFinal": "Neow, Goddess of Life", }
[docs] class ShopContents: """Contains one floor's shop contents. This can be used either for contents or purchases.""" def __init__(self): self.cards: list[SingleCard] = [] self.relics: list[Relic] = [] self.potions: list[Potion] = []
[docs] def add_card(self, card: str): """Add a card to this floor's contents.""" self.cards.append(get_card(card))
[docs] def add_relic(self, relic: str): """Add a relic to this floor's contents.""" self.relics.append(get(relic))
[docs] def add_potion(self, potion: str): """Add a potion to this floor's contents.""" self.potions.append(get(potion))
[docs] class FileParser(ABC): _variables_map = { "current_hp": "Current HP", "max_hp": "Max HP", "gold": "Gold", "floor_time": "Time spent in the floor (seconds)", "card_count": "Cards in the deck", "relic_count": "Relics", "potion_count": "Potions", } _graph_types = { "embed": "text/html", "image": "image/png", } _potion_mapping = { "use": ("PotionUseLog", "potion_use_per_floor"), "alchemize": ("potionsObtainedAlchemizeLog", "potions_obtained_alchemize"), "entropic": ("potionsObtainedEntropicBrewLog", "potions_obtained_entropic_brew"), "discarded": ("PotionDiscardLog", "potion_discard_per_floor"), } prefix: str = "" #: String to prepend to most data access. done: bool = False #: Whether the run is over. def __init__(self, data: dict[str, Any]): self._data = data self.neow_bonus = NeowBonus(self) self._cache: dict[str, Any] = {"self": self} # this lets us do on-the-fly debugging self._character: str | None = None self._graph_cache: dict[tuple[str, str, tuple, str | None, str | None], str | bytes] = {} def __str__(self): return f"{self.__class__.__name__}<{self.timestamp}>" def _get_boss_chest(self) -> dict[str, str | list[str]]: if "boss_chest_iter" not in self._cache: self._cache["boss_chest_iter"] = iter(self._data[self.prefix + "boss_relics"]) try: # with a savefile, it's possible to try to get the same floor twice, which will the last one return next(self._cache["boss_chest_iter"]) # type: ignore except StopIteration: return self._data[self.prefix + "boss_relics"][-1] def graph(self, req: Request) -> Response: if "view" not in req.query or "type" not in req.query: raise HTTPForbidden(reason="Needs 'view' and 'type' params") if req.query["type"] not in self._graph_types: raise HTTPNotImplemented(reason=f"Display type {req.query['type']} is undefined") graph_type = req.match_info["type"] display_type = req.query["type"] items = req.query["view"].split(",") label = req.query.get("label") title = req.query.get("title") to_cache = (graph_type, display_type, tuple(items), label, title) if to_cache not in self._graph_cache: try: self._graph_cache[to_cache] = self._generate_graph(graph_type, display_type, items, label, title, allow_private=False) except ValueError as e: raise HTTPForbidden(reason=e.args[0]) except TypeError: raise HTTPNotFound() return Response(body=self._graph_cache[to_cache], content_type=self._graph_types[display_type]) def bar(self, dtype: str, items: Iterable[str], label: str | None = None, title: str | None = None, *, allow_private: bool = False) -> str | bytes: if dtype not in self._graph_types: raise ValueError(f"Display type {dtype} is undefined") to_cache = ("bar", dtype, tuple(items), label, title) if to_cache not in self._graph_cache: self._graph_cache[to_cache] = self._generate_graph("bar", dtype, items, label, title, allow_private=allow_private) return self._graph_cache[to_cache] def _generate_graph(self, graph_type: str, display_type: str, items: Iterable[str], ylabel: str | None, title: str | None, *, allow_private: bool) -> str | bytes: if plt is None: raise ValueError("matplotlib is not installed, graphs cannot be used") totals: dict[str, list[int]] = {} ends = [] floors = [] for arg in items: if arg.startswith("_") and not allow_private: raise ValueError(f"Cannot access private attribute {arg}.") totals[arg] = [] if arg not in self._variables_map: logger.warning(f"Graph parameter {arg!r} may not be properly handled.") for name, d in totals.items(): val = getattr(self.neow_bonus, name, None) if val is not None: if not floors: floors.append(0) d.append(val) for node in self.path: floors.append(node.floor) if node.end_of_act: ends.append(node.floor) for name, d in totals.items(): val = getattr(node, name, 0) if callable(val): try: val = val() except TypeError: raise ValueError(f"Cannot call function {name!r} that requires parameters.") try: val + 0 except TypeError: raise ValueError(f"Cannot use non-integer {name!r} for graphs.") else: d.append(val) fig, ax = plt.subplots() match graph_type: case "plot": func = ax.plot case "scatter": func = ax.scatter case "bar": func = ax.bar case "stem": func = ax.stem case a: raise TypeError(f"Could not understand graph type {a}") if display_type != "embed": for num in ends: plt.axvline(num, color="black", linestyle="dashed") for name, d in totals.items(): func(floors, d, label=self._variables_map.get(name, name)) ax.legend() plt.xlabel("Floor") if ylabel is not None: plt.ylabel(ylabel) elif len(totals) == 1: label = tuple(totals)[0] plt.ylabel(self._variables_map.get(label, label)) plt.xlim(left=0) plt.ylim(bottom=0) if title is not None: # doesn't appear to work with mpld3 plt.suptitle(title) match display_type: case "embed": if fig_to_html is None: raise ValueError("mpld3 isn't installed, cannot embed graphs. Use 'image' display type") value: str = fig_to_html(fig) plt.close(fig) return value case "image": with io.BytesIO() as file: plt.savefig(file, format="png", transparent=True) plt.close(fig) return file.getvalue() @property @abstractmethod def floor(self) -> int: """The last or latest floor.""" raise NotImplementedError @property @abstractmethod def timestamp(self) -> datetime.datetime: """Save time, as UTC.""" raise NotImplementedError @property @abstractmethod def timedelta(self) -> datetime.timedelta: """Time delta, semantics depending on the subclass.""" raise NotImplementedError @property @abstractmethod def profile(self) -> Profile: """The profile this run is played on.""" raise NotImplementedError @property @abstractmethod def character_streak(self) -> StreakInfo: """The run position in the character streak.""" raise NotImplementedError @property @abstractmethod def rotating_streak(self) -> StreakInfo: """The run position in the rotating streak.""" raise NotImplementedError @property @abstractmethod def _neow_data(self) -> tuple[dict[str, list[str] | int], list[str], list[str]]: """Neow Bonus data. For internal use only.""" raise NotImplementedError @property def _neow_picked(self) -> tuple[str, str]: """Neow bonus and cost picked. For internal use only.""" return (self._data["neow_bonus"], self._data["neow_cost"]) @property def display_name(self) -> str: """The display name to be used on the website.""" return "" @property def skipped_rewards(self) -> tuple[RelicRewards, PotionRewards]: """All of the skipped relics and potions this run.""" rels = collections.defaultdict(list) pots = collections.defaultdict(list) if self.prefix == "metric_": # savefile skipped = self._data["basemod:mod_saves"].get("RewardsSkippedLog") else: skipped = self._data.get("rewards_skipped") if skipped: for choice in skipped: if (r := choice["relics"]): rels[choice["floor"]].extend(get(x) for x in r) if (p := choice["potions"]): pots[choice["floor"]].extend(get(x) for x in p) return rels, pots @property def character(self) -> str | None: """Human-readable character name.""" if self._character is None: return None c = _chars.get(self._character) if c is None: c = self._character.replace("_", " ").title() if c.startswith("The "): # just fix it after the fact c = c[4:] return c @property def modded(self) -> bool: """Whether the run uses a modded character.""" return self.character not in ("Ironclad", "Silent", "Defect", "Watcher") @property def current_hp_counts(self) -> list[int]: """The current HP in all the floors, including Neow.""" return [self.neow_bonus.current_hp] + self._data[self.prefix + "current_hp_per_floor"] @property def max_hp_counts(self) -> list[int]: """The max HP in all the floors, including Neow.""" return [self.neow_bonus.max_hp] + self._data[self.prefix + "max_hp_per_floor"] @property def gold_counts(self) -> list[int]: """The gold in all the floors, including Neow.""" return [self.neow_bonus.gold] + self._data[self.prefix + "gold_per_floor"] @property def potions(self) -> PotionRewards: """The potions obtained through normal rewards or purchases.""" res = collections.defaultdict(list) for d in self._data[self.prefix + "potions_obtained"]: res[d["floor"]].append(get(d["key"])) return res def _handle_potions(self, key: str) -> PotionRewards: final = collections.defaultdict(list) if key not in self._potion_mapping: raise ValueError(f"Key {key} is not a valid potion action.") mapping = self._data idx = 1 if self.prefix == "metric_": # savefile mapping = mapping["basemod:mod_saves"] idx = 0 mapkey = self._potion_mapping[key][idx] # this needs RHP, so it might not be present # but we want a list anyway, which is why we iterate like this for i in range(self.floor): potions = [] try: # it's possible the key doesn't exist, but we don't want it to error for x in mapping[mapkey][i]: potions.append(get(x)) except (KeyError, IndexError): # Either we don't have RHP, or the floor isn't stored somehow pass if potions: # we add one here because the internal list starts at floor 1 # so index 0 is floor 1, which we need to correct for here final[i+1] = potions return final @property def potions_use(self) -> PotionRewards: """The potions that were used during the run.""" return self._handle_potions("use") @property def potions_alchemize(self) -> PotionRewards: """The potions that were obtained from Alchemize during the run.""" return self._handle_potions("alchemize") @property def potions_entropic(self) -> PotionRewards: """The potions that were obtained from Entropic Brew during the run.""" return self._handle_potions("entropic") @property def potions_discarded(self) -> PotionRewards: """The potions that were discarded during the run.""" return self._handle_potions("discarded") @property def boss_relics(self) -> list[BossRelicChoice]: """Picked and skipped boss relics this run.""" rels: list[dict] = self._data[f"{self.prefix}boss_relics"] ret = [] for choices in rels: picked = None if "picked" in choices: picked = get(choices["picked"]) skipped = tuple(get(x) for x in choices["not_picked"]) ret.append( (picked, skipped) ) return ret @property def ascension_level(self) -> int: """The Ascension level of the run.""" return self._data["ascension_level"] @property def playtime(self) -> int: """Time between the start of the run and the latest save.""" return self._data[self.prefix + "playtime"] @property @abstractmethod def keys(self) -> KeysObtained: """The Heart keys obtained this run.""" raise NotImplementedError @property def card_choices(self) -> tuple[CardRewards, CardRewards]: """A tuple of (picked, skipped) cards this run.""" picked = collections.defaultdict(list) skipped = collections.defaultdict(list) for d in self._data[self.prefix + "card_choices"]: if (c := d["picked"]) != "SKIP": picked[d["floor"]].append(get_card(c)) for card in d["not_picked"]: skipped[d["floor"]].append(get_card(card)) return picked, skipped @property @abstractmethod #PRIV# why is this like that def _master_deck(self) -> list[str]: raise NotImplementedError
[docs] def get_cards(self) -> Generator[CardData, None, None]: """Yield each card with no repetition, with count.""" master_deck = self._master_deck for card in set(master_deck): yield CardData(card, master_deck)
[docs] def get_meta_scaling_cards(self) -> list[tuple[str, int]]: """Return info about the meta-scaling cards.""" return []
@property def cards(self) -> Generator[str, None, None]: """All the cards in the deck, with upgrade.""" for card in self.get_cards(): yield from card.as_cards()
[docs] def get_purchases(self) -> collections.defaultdict[int, ShopContents]: """Return a mapping of purchases for a given floor.""" bought: collections.defaultdict[int, ShopContents] = collections.defaultdict(ShopContents) for i, purchased in enumerate(self._data[self.prefix + "item_purchase_floors"]): value = self._data[self.prefix + "items_purchased"][i] name, _, upgrades = value.partition("+") item = get(name) match item.cls_name: case "card": bought[purchased].add_card(value) case "relic": bought[purchased].add_relic(value) case "potion": bought[purchased].add_potion(value) return bought
[docs] def get_shop_contents(self) -> collections.defaultdict[int, ShopContents]: """Return a mapping of unpurchased shop contents for a given floor.""" d = () if "shop_contents" in self._data: d = self._data["shop_contents"] elif "basemod:mod_saves" in self._data: d = self._data["basemod:mod_saves"].get("ShopContentsLog", ()) results = collections.defaultdict(ShopContents) for data in d: results[data["floor"]] = contents = ShopContents() for relic in data["relics"]: contents.add_relic(relic) for card in data["cards"]: contents.add_card(card) for potion in data["potions"]: contents.add_potion(potion) return results
@property def _removals(self) -> list[tuple[str, int]]: event_removals = [] for event in self._data[self.prefix + "event_choices"]: for removed in event.get("cards_removed", []): event_removals.append((removed, event["floor"])) store_removals = zip(self._data.get(self.prefix + "items_purged", []), self._data.get(self.prefix + "items_purged_floors", [])) # missing Empty Cage all_removals = [] for card in self.neow_bonus.cards_removed: all_removals.append((card.name, 0)) all_removals.extend(event_removals) all_removals.extend(store_removals) return all_removals
[docs] def get_removals(self) -> Generator[CardData, None, None]: """Yield each removed card with no repetition, with count.""" removals = [x[0] for x in self._removals] for card in set(removals): # remove duplicates yield CardData(card, removals)
@property def has_removals(self) -> bool: """Whether we removed any card at all.""" return bool(self._removals) @property def removals(self) -> Generator[ItemFloor, None, None]: """Every (name, floor) tuple of removed card and their floor.""" for card, floor in self._removals: cdata = CardData(card, []) yield (cdata.name, floor)
[docs] def master_deck_as_html(self): """Return the cards from the deck suitable for the website.""" return self._cards_as_html(self.get_cards())
[docs] def removals_as_html(self): """Return the removed cards suitable for the website.""" return self._cards_as_html(self.get_removals())
def _cards_as_html(self, cards: Iterable[CardData]) -> Generator[str, None, None]: text = ( '<a class="card"{color} href="https://raw.githubusercontent.com/OceanUwU/slaytabase/main/docs/{mod}/cards/{card_url}.png" target="_blank">' '<svg width="32" height="32">' '<image width="32" height="32" xlink:href="{website}/static/card/Back_{card.color}.png"></image>' '<image width="32" height="32" xlink:href="{website}/static/card/Desc_{card.color}.png"></image>' '<image width="32" height="32" xlink:href="{website}/static/card/Type_{card.type_safe}.png"></image>' '<image width="32" height="32" xlink:href="{website}/static/card/Banner_{banner}.png"></image>' '</svg><span>{card.display_name}</span></a>' ) def new_color() -> dict[str | None, list[CardData]]: return {"Rare": [], "Uncommon": [], "Common": [], None: []} order = ["Red", "Green", "Blue", "Purple", "Colorless", "Curse"] content = collections.defaultdict(new_color) for card in cards: if card.color not in order: order.insert(0, card.color) content[card.color][card.rarity_safe].append(card) final = [] for color in order: for rarity, all_cards in content[color].items(): all_cards.sort(key=lambda x: f"{x.name}{x.upgrades}") for card in all_cards: format_map = { "color": ' style="color:#a0ffaa"' if card.upgrades else "", # make it green when upgraded "website": config.server.url, "banner": rarity or "Common", "mod": urllib.parse.quote(card.card.mod or "Slay the Spire").lower(), "card_url": format_for_slaytabase(card.card.internal), "card": card, } final.append(text.format_map(format_map)) step, rem = divmod(len(final), 3) end = [] while rem: end.append(final.pop(step*rem)) rem -= 1 end.reverse() for i in range(step): yield from final[i::step] yield from end @property def relics(self) -> list[RelicData]: if "relics" not in self._cache: self._cache["relics"] = [] for relic in self._data["relics"]: value = RelicData(self, relic) self._cache["relics"].append(value) return list(self._cache["relics"]) @property def relics_bare(self) -> list[Relic]: # could be a generator, but we want easy contain check return [x.relic for x in self.relics] @property def relics_obtained(self) -> RelicRewards: res = collections.defaultdict(list) for relic in self._data[self.prefix + "relics_obtained"]: res[relic["floor"]].append(get(relic["key"])) return res @property def seed(self) -> str: """The seed being used for the pRNG in this run.""" c = "0123456789ABCDEFGHIJKLMNPQRSTUVWXYZ" try: seed = int(self._data["seed"]) # might be stored as a str except KeyError: seed = int(self._data["seed_played"]) # this is a bit weird, but lets us convert a negative number, if any, into a positive one num = int.from_bytes(seed.to_bytes(20, "big", signed=True).lstrip(b"\xff"), "big") s = [] while num: num, i = divmod(num, 35) s.append(c[i]) s.reverse() # everything's backwards, for some reason... but this works return "".join(s) @property def is_seeded(self) -> bool: """Whether a seed was manually chosen.""" if "seed_set" in self._data: return self._data["seed_set"] return self._data["chose_seed"] @property def path(self) -> list[NodeData]: # note: caching may not be needed """The run's path, cached.""" if "path" not in self._cache: self._cache["path"] = [] floor_time: tuple[int, ...] if "basemod:mod_saves" in self._data: floor_time = self._data["basemod:mod_saves"].get("FloorExitPlaytimeLog", ()) else: floor_time = self._data.get("floor_exit_playtime", ()) prev = 0 card_count = self.neow_bonus.card_delta() relic_count = self.neow_bonus.relic_delta() potion_count = self.neow_bonus.potion_delta() fights_count = self.neow_bonus.fights_delta() turns_count = self.neow_bonus.turns_delta() for node, cached in _get_nodes(self, self._cache.pop("old_path", None)): try: t = floor_time[node.floor - 1] except IndexError: t = 0 if cached: # don't recompute the deltas -- just grab their cached counts card_count = node.card_count relic_count = node.relic_count potion_count = node.potion_count fights_count = node.fights_count turns_count = node.turns_count else: card_count += node.card_delta() node.card_count = card_count relic_count += node.relic_delta() node.relic_count = relic_count potion_count += node.potion_delta() node.potion_count = potion_count fights_count += node.fights_delta() node.fights_count = fights_count turns_count += node.turns_delta() node.turns_count = turns_count node.floor_time = t - prev prev = t self._cache["path"].append(node) return list(self._cache["path"]) @property def modifiers(self) -> list[str]: return self._data.get("daily_mods", []) @property def modifiers_with_desc(self) -> list[str]: if self.modifiers: modifiers_with_desc = [get_run_mod(mod) for mod in self.modifiers] return modifiers_with_desc return [] @property @abstractmethod def score(self) -> int: raise NotImplementedError @property @abstractmethod def score_breakdown(self) -> list[str]: raise NotImplementedError
[docs] def get_floor(self, floor: int) -> BaseNode | None: if floor == 0: return self.neow_bonus for node in self.path: if node.floor == floor: return node return None
def _get_nodes(parser: FileParser, maybe_cached: list[NodeData] | None) -> Generator[tuple[NodeData, bool], None, None]: #PRIV# """Get the map nodes. This should only ever be called from 'FileParser.path' to get the cache.""" prefix = parser.prefix on_map = parser._data[prefix + "path_taken"] # maybe_cached will not be None if this is a savefile we're iterating through # which means we already know previous floors, so just use that. # to be safe, regenerate the last floor, since it might have changed # (e.g. the last time we saw it, we were in-combat, and now we're out of it) # this is also used for run files for which we had the savefile if maybe_cached: maybe_cached.pop() nodes = [] error = False taken_len = len(parser._data[prefix + "path_taken"]) actual_len = len([x for x in parser._data[prefix + "path_per_floor"] if x is not None]) last_changed = 0 offset = 1 for floor, actual in enumerate(parser._data[prefix + "path_per_floor"], 1): iterate = True # Slay the Streamer boss pick if actual_len > taken_len and actual == "T" and floor == last_changed + 10: taken_len += 1 # keep track offset += 1 iterate = False # make sure we step through the iterator even if it's cached node = [actual, None] if iterate and node[0] is not None: node[1] = on_map[floor-offset] elif iterate: offset += 1 nodes.append(node) if maybe_cached: maybe_node = maybe_cached.pop(0) if floor == maybe_node.floor: # if it's not, then something's wrong. just regen it yield maybe_node, True continue if not iterate: continue match node: case ("M", "M"): cls = NormalEncounter case ("M", "?"): cls = EventEncounter case ("E", "E"): cls = EliteEncounter case ("E", "?"): # only happens if the Deadly Events modifier is on cls = EventElite case ("$", "$"): cls = Merchant case ("$", "?"): cls = EventMerchant case ("T", "T"): cls = Treasure case ("T", "?"): cls = EventTreasure case ("?", "?"): cls = event_node # not actually a NodeData subclass, but it returns one case ("R", "R"): cls = Campfire case ("B", "BOSS"): cls = Boss case ("C", "C"): cls = Courier case ("-", "-"): cls = Empty case ("P", "P"): cls = SWF case (None, None): if floor < 50: # kind of a hack for the first two acts cls = BossChest elif len(parser._data[prefix + "max_hp_per_floor"]) < floor: cls = Victory else: cls = Act4Transition case (a, b): logger.warning(f"Error: the combination of map node {b!r} and content {a!r} is undefined (run: {getattr(parser, 'name', 'savefile')})") error = True continue if cls.end_of_act: last_changed = floor try: value: NodeData = cls(parser, floor) except ValueError: # this can happen for savefiles if we're on the latest floor if taken_len == floor: continue # we're on the last floor raise else: yield value, False if error: logger.error("\n".join(f"Actual: {str(x):<4} | Map: {y}" for x, y in nodes)) class KeysObtained: """Contain information about the obtained keys.""" def __init__(self): self.ruby_key_obtained = False self.ruby_key_floor = 0 self.emerald_key_obtained = False self.emerald_key_floor = 0 self.sapphire_key_obtained = False self.sapphire_key_floor = 0 def as_list(self): return [ ("Emerald Key", self.emerald_key_obtained, self.emerald_key_floor), ("Ruby Key", self.ruby_key_obtained, self.ruby_key_floor), ("Sapphire Key", self.sapphire_key_obtained, self.sapphire_key_floor), ] class RelicData: """Contain information for Spire relics.""" def __init__(self, parser: FileParser, relic: str): self.parser = parser self.relic: Relic = get(relic) self._description = None def __str__(self) -> str: return self.relic.name def __repr__(self) -> str: return f"{self.__class__.__name__}({self.parser}, {self.relic.name})" def description(self) -> str: if self._description is None: desc = [] obtained: BaseNode = self.parser.neow_bonus node = None for node in self.parser.path: if self.relic in node.relics: obtained = node desc.append(f"Obtained on floor {obtained.floor}") if node is not None: desc.extend(self.get_details(obtained, node)) # node will be the last node self._description = "\n".join(desc) return self._description def escaped_description(self) -> str: return self.description().replace("\n", "<br>").replace("'", "\\'") def get_stats(self) -> int | float | str | list[str] | None: #PRIV# if "basemod:mod_saves" in self.parser._data: return self.parser._data["basemod:mod_saves"].get(f"stats_{self.relic.internal}") if "relic_stats" in self.parser._data: return self.parser._data["relic_stats"].get(self.relic.internal) def get_details(self, obtained: NodeData, last: NodeData) -> list[str]: desc = [] try: text = get_relic_stats(self.relic.internal) # FIXME except KeyError: # no stats for these return [] stats = self.get_stats() if stats is None: return [] per_turn = True if self.relic.name == "White Beast Statue": # if this is a savefile, only the last number matters. run files should be unaffected stats = [stats[-1]] elif self.relic.name == "Snecko Eye": # special handling for this per_turn = False if (c := sum(stats[:-1])) > 0: stats[-1] = stats[-1] / c if isinstance(stats, int): desc.append(text[0] + str(stats)) desc.extend(text[1:]) elif isinstance(stats, float): # only Frozen Eye minutes, seconds = divmod(stats, 60) if minutes: desc.append(f"{text[0]}{minutes:.0f}m {seconds:.2f}s") else: desc.append(f"{text[0]}{seconds:.2f}s") elif isinstance(stats, str): # bottles desc.append(text[0] + get(stats).name) elif isinstance(stats, list): if not stats: # e.g. Whetstone upgrading nothing desc.append(f"{text[0]}<nothing>") elif isinstance(stats[0], str): stats = [get(x).name for x in stats] desc.append(f"{text[0]}\n- "+ "\n- ".join(stats)) else: text_iter = iter(text) for stat in stats: stat_str = str(stat) if isinstance(stat, float): stat_str = f"{stat:.2f}" desc.append(next(text_iter) + stat_str) if per_turn: num = stat / max((last.turns_count - obtained.turns_count), 1) desc.append(f"Per turn: {num:.2f}") num = stat / max((last.fights_count - obtained.fights_count), 1) desc.append(f"Per combat: {num:.2f}") else: desc.append(f"Unable to parse stats for {self.name}") return desc @property def mod(self) -> str: if not self.relic.mod: return "Slay the Spire" return self.relic.mod @property def image(self) -> str: name = self.relic.internal if ":" in name: name = name[name.index(":")+1:] return f"{format_for_slaytabase(name)}.png" @property def name(self) -> str: return self.relic.name class CardData: # TODO: metadata + scaling cards (for savefile) def __init__(self, card: str | SingleCard, cards_list: Iterable[str], meta: int = 0): self.orig = card if isinstance(card, str): card = get_card(card) self._cards_list = list(cards_list) self.single = card self.card: Card = card.card self.meta = meta self.upgrades = card.upgrades def as_cards(self): for i in range(self.count): yield self.name @property def color(self) -> str: return self.card.color @property def type(self) -> str: return self.card.type @property def type_safe(self) -> str: if self.type not in ("Attack", "Skill", "Power"): return "Skill" return self.type @property def rarity(self) -> str: return self.card.rarity @property def rarity_safe(self) -> str | None: if self.rarity not in ("Rare", "Uncommon", "Common"): return None return self.rarity @property def count(self) -> int: if self._cards_list: return self._cards_list.count(self.orig) return 1 # support standalone card stuff @property def name(self) -> str: match self.upgrades: case 0: return self.card.name case 1: return f"{self.card.name}+" case a: return f"{self.card.name}+{a}" @property def display_name(self) -> str: if self.count > 1: return f"{self.count}x {self.name}" return self.name
[docs] class NodeData(BaseNode): """Contain relevant information for Spire nodes. To instantiate a subclass, call the class with a :class:`FileParser` instance (either :class:`src.runs.RunParser` or :class:`src.save.Savefile`) and the floor number. To change behaviour, you need to subclass the relevant subclass and alter the behaviour there. Some features rely on objects being instances of specific NodeData subclasses (e.g. :class:`Treasure`) and will fail otherwise. There is no mechanism currently for overriding which subclasses are returned by the node path parser. """ map_icon = "" #: The map icon as present under the static/icons/ folder def __init__(self, parser: FileParser, floor: int, *extra): # TODO: Keep track of the deck per node """Create a NodeData instance from a parser and floor number.""" if not (self.room_type and self.map_icon): raise ValueError(f"Cannot create NodeData subclass {self.__class__.__name__!r}") super().__init__(parser, *extra) self.floor = floor if not self._set_hp_gold(floor): # in case our current floor doesn't have data, get previous floor # this can happen with the post-Heart victory screen # and also maybe during an ongoing run while getting the current node # if this assignment fails too, just let it self._set_hp_gold(floor - 1) self._cache = {} def _set_hp_gold(self, floor: int) -> bool: """Set the gold as well as current and max HP for this node. Return True if assignment succeeded, False otherwise.""" try: # delay assignment in case a later one fails # we don't want some of them to be assigned and others not cur_hp = self.parser.current_hp_counts[floor] max_hp = self.parser.max_hp_counts[floor] gold = self.parser.gold_counts[floor] except IndexError: return False self.current_hp = cur_hp self.max_hp = max_hp self.gold = gold return True
[docs] def description(self) -> str: if "description" not in self._cache: self._cache["description"] = super().description() return self._cache["description"]
[docs] def get_description(self, to_append: dict[int, list[str]]): if self.potions: to_append[10].append("Potions obtained:") to_append[10].extend(f"- {x.name}" for x in self.potions) if self.used_potions: to_append[12].append("Potions used:") to_append[12].extend(f"- {x.name}" for x in self.used_potions) if self.potions_from_alchemize: to_append[14].append("Potions obtained from Alchemize:") to_append[14].extend(f"- {x.name}" for x in self.potions_from_alchemize) if self.potions_from_entropic: to_append[16].append("Potions obtained from Entropic Brew:") to_append[16].extend(f"- {x.name}" for x in self.potions_from_entropic) if self.discarded_potions: to_append[18].append("Potions discarded:") to_append[18].extend(f"- {x.name}" for x in self.discarded_potions) if self.skipped_potions: to_append[20].append("Potions skipped:") to_append[20].extend(f"- {x.name}" for x in self.skipped_potions) if self.relics: to_append[30].append("Relics obtained:") to_append[30].extend(f"- {x.name}" for x in self.relics) if self.skipped_relics: to_append[32].append("Relics skipped:") to_append[32].extend(f"- {x.name}" for x in self.skipped_relics) if self.picked: to_append[34].append("Picked:") to_append[34].extend(f"- {x}" for x in self.picked) if self.skipped: to_append[36].append("Skipped:") to_append[36].extend(f"- {x}" for x in self.skipped)
[docs] class EncounterBase(NodeData): """A base data class for Spire node encounters.""" def __init__(self, parser: FileParser, floor: int, *extra): super().__init__(parser, floor, *extra) for damage in parser._data[parser.prefix + "damage_taken"]: #PRIV# if damage["floor"] == floor: break else: raise ValueError("no fight result yet") self._damage = damage
[docs] def get_description(self, to_append: dict[int, list[str]]): if self.name != self.fought: to_append[4].append(f"Fought {self.fought}") to_append[4].append(f"{self.damage} damage") to_append[4].append(f"{self.turns_delta()} turns")
[docs] def fights_delta(self) -> int: return 1
[docs] def turns_delta(self) -> int: return int(self._damage["turns"])
@property def name(self) -> str: enemies = self._damage["enemies"] return _enemies.get(enemies, enemies) @property def fought(self) -> str: """A human-readable name of the battle fought this floor.""" fought = self._damage["enemies"] return _enemies.get(fought, fought) @property def damage(self) -> int: """How much damage was taken this fight.""" return int(self._damage["damage"])
[docs] class NormalEncounter(EncounterBase): room_type = "Enemy" map_icon = "fight_normal.png"
[docs] class EventEncounter(EncounterBase): room_type = "Unknown (Enemy)" map_icon = "event_fight.png"
[docs] class Treasure(NodeData): room_type = "Treasure" map_icon = "treasure_chest.png" key_relic: Optional[Relic] #: The relic that was skipped for the Sapphire Key on this floor, if any. blue_key: bool #: Whether we acquired the Sapphire Key on this floor. def __init__(self, parser: FileParser, floor: int, *extra): super().__init__(parser, floor, *extra) self.key_relic = self._get_relic() self.blue_key = (self.key_relic is not None) def _get_relic(self) -> Optional[Relic]: #PRIV# d = self.parser._data.get("basemod:mod_saves", ()) if "BlueKeyRelicSkippedLog" in d: if d["BlueKeyRelicSkippedLog"]["floor"] == self.floor: return get(d["BlueKeyRelicSkippedLog"]["relicID"]) elif "blue_key_relic_skipped_log" in self.parser._data: if self.parser._data["blue_key_relic_skipped_log"]["floor"] == self.floor: return get(self.parser._data["blue_key_relic_skipped_log"]["relicID"])
[docs] def get_description(self, to_append: dict[int, list[str]]): if self.blue_key: to_append[6].append(f"Skipped {self.key_relic} for the Sapphire key")
[docs] class EventTreasure(Treasure): room_type = "Unknown (Treasure)" map_icon = "event_chest.png"
[docs] class EliteEncounter(EncounterBase): room_type = "Elite" map_icon = "fight_elite.png" def __init__(self, parser: FileParser, floor: int, *extra): super().__init__(parser, floor, *extra) if "basemod:mod_saves" in parser._data: key_floor = parser._data["basemod:mod_saves"].get("greenKeyTakenLog") else: key_floor = parser._data.get("green_key_taken_log") self.has_key = (key_floor is not None and int(key_floor) == floor)
[docs] def get_description(self, to_append: dict[int, list[str]]): if self.has_key: to_append[6].append("Got the Emerald Key")
[docs] class EventElite(EliteEncounter): room_type = "Unknown (Elite)" map_icon = "event.png"
def event_node(parser: FileParser, floor: int, *extra) -> BaseNode: events = [] for event in parser._data[parser.prefix + "event_choices"]: #PRIV# if event["floor"] == floor: events.append(event) if not events: return EmptyEvent(parser, floor, *extra) if events[0]["event_name"] == "Colosseum": return Colosseum(parser, floor, events, *extra) if len(events) != 1: for a in events: for b in events: if a != b: # I'm not quite sure how this happens, but sometimes an event will be in twice? return AmbiguousEvent(parser, floor, events, *extra) event = events[0] for dmg in parser._data[parser.prefix + "damage_taken"]: if dmg["floor"] == floor: # not passing dmg in, as EncounterBase fills it in return EventFight(parser, floor, event, *extra) return Event(parser, floor, event, *extra) event_node.end_of_act = False class EmptyEvent(NodeData): room_type = "Unknown (Bugged)" map_icon = "event.png" def get_description(self, to_append: dict[int, list[str]]): to_append[100].append( "Something happened here, but I'm not sure what...\n" "This is a bug with a mod. Please report this to the mod creators:\n" "'Missing event data in JSON'\n" "(Provide the event name if you can find it)" ) class AmbiguousEvent(NodeData): room_type = "Unknown (Ambiguous)" map_icon = "event.png" def __init__(self, parser: FileParser, floor: int, events: list[dict[str, Any]], *extra): super().__init__(parser, floor, *extra) self._events = events def get_description(self, to_append: dict[int, list[str]]): to_append[100].append( "This event is ambiguous; multiple events map to it:\n" + ", ".join(x["event_name"] for x in self._events) + " -\n" + "This is a bug with a mod. Please report this to the mod creators." )
[docs] class Event(NodeData): room_type = "Unknown" map_icon = "event.png" def __init__(self, parser: FileParser, floor: int, event: dict[str, Any], *extra): super().__init__(parser, floor, *extra) self._event = event
[docs] def get_description(self, to_append: dict[int, list[str]]): i = 40 if type(self) is EventFight: i = 2 to_append[i].append(f"Option taken:\n- {self.choice}") i = 42 # 42 44 46 48 50 52 for x in ("damage_healed", "damage_taken", "max_hp_gained", "max_hp_lost", "gold_gained", "gold_lost"): i += 2 val = getattr(self, x) if val: name = x.replace("_", " ").capitalize().replace("hp", "HP") to_append[i].append(f"{name}: {val}") # 54 56 58 60 62 for x in ("cards_transformed", "cards_obtained", "cards_removed", "cards_upgraded", "relics_lost"): i += 2 val = getattr(self, x) if val: name = x.replace("_", " ").capitalize() to_append[i].append(f"{name}:") to_append[i].extend(f"- {card}" for card in val)
[docs] def potion_delta(self) -> int: value = super().potion_delta() if self._event["event_name"] == "WeMeetAgain" and self.choice == "Gave Potion": value -= 1 return value
@property def name(self) -> str: return get_event(self._event["event_name"]) # TODO: Move all/most of these to the base class? @property def choice(self) -> str: """Which option was picked for this event.""" return self._event["player_choice"] @property def damage_healed(self) -> int: """How much health was gained during this event.""" return self._event["damage_healed"] @property def damage_taken(self) -> int: """How much health was lost during this event.""" return self._event["damage_taken"] @property def max_hp_gained(self) -> int: """How much max HP was gained during this event.""" return self._event["max_hp_gain"] @property def max_hp_lost(self) -> int: """How much max HP was lost during this event.""" return self._event["max_hp_loss"] @property def gold_gained(self) -> int: """How much gold was gained during this event.""" return self._event["gold_gain"] @property def gold_lost(self) -> int: """How much gold was lost during this event.""" return self._event["gold_loss"] @property def cards_transformed(self) -> list[SingleCard]: l = super().cards_transformed for x in self._event.get("cards_transformed", ()): l.append(get_card(x)) return l @property def cards_obtained(self) -> list[SingleCard]: l = super().cards_obtained for x in self._event.get("cards_obtained", ()): l.append(get_card(x)) return l @property def cards_removed(self) -> list[SingleCard]: l = super().cards_removed for x in self._event.get("cards_removed", ()): l.append(get_card(x)) return l @property def cards_upgraded(self) -> list[SingleCard]: l = super().cards_upgraded for x in self._event.get("cards_upgraded", ()): l.append(get_card(x)) return l @property def relics(self) -> list[Relic]: l = super().relics for x in self._event.get("relics_obtained", ()): l.append(get(x)) return l @property def relics_lost(self) -> list[Relic]: l = super().relics_lost for x in self._event.get("relics_lost", ()): l.append(get(x)) return l
[docs] class EventFight(Event, EncounterBase): """This is a subclass for fights that happen in events. This works for Dead Adventurer, Masked Bandits, etc. This does *not* work for the Colosseum fight (use :class:`Colosseum` instead) """
[docs] class Colosseum(Event): def __init__(self, parser: FileParser, floor: int, events: list[dict[str, Any]], *extra): event = { "damage_healed": 0, "gold_gain": 0, "player_choice": "Fought Taskmaster + Nob", "damage_taken": 0, "max_hp_gain": 0, "max_hp_loss": 0, "event_name": "Colosseum", "gold_loss": 0, } if len(events) == 1: event["player_choice"] = "Escaped" super().__init__(parser, floor, event, *extra) dmg = [] for damage in parser._data[parser.prefix + "damage_taken"]: if damage["floor"] == floor: dmg.append(damage) self._damages = dmg
[docs] def get_description(self, to_append: dict[int, list[str]]): for i, dmg in enumerate(self._damages, 1): to_append[4].append(f"Fight {i} ({dmg['enemies']}):") to_append[4].append(f"{dmg['damage']} damage") to_append[4].append(f"{dmg['turns']} turns")
@property def damage(self) -> int: """How much damage was taken over both fights, if relevant.""" return sum(d["damage"] for d in self._damages)
[docs] def fights_delta(self) -> int: return len(self._damages)
[docs] def turns_delta(self) -> int: return sum(d["turns"] for d in self._damages)
[docs] class Merchant(NodeData): room_type = "Merchant" map_icon = "shop.png" contents: ShopContents #: The non-purchased contents of the shop. bought: ShopContents #: All purchased goods from this shop. purged: list[str] #: All cards removed this floor. # XXX: should purged be a SingleCard? def __init__(self, parser: FileParser, floor: int, *extra): super().__init__(parser, floor, *extra) self.contents = parser.get_shop_contents()[floor] self.bought = parser.get_purchases()[floor] self.purged = [] for card, floor in self.parser.removals: if floor == self.floor: self.purged.append(card)
[docs] def get_description(self, to_append: dict[int, list[str]]): if self.purged: to_append[70].append(f"* Removed {', '.join(self.purged)}") if self.contents: to_append[72].append("Skipped:") if self.contents.relics: to_append[74].append("* Relics") to_append[74].extend(f" - {x.name}" for x in self.contents.relics) if self.contents.cards: to_append[76].append("* Cards") to_append[76].extend(f" - {x}" for x in self.contents.cards) if self.contents.potions: to_append[78].append("* Potions") to_append[78].extend(f" - {x.name}" for x in self.contents.potions)
@property def picked(self) -> list[str]: return super().picked + self.bought.cards @property def relics(self) -> list[Relic]: return super().relics + self.bought.relics @property def potions(self) -> list[Potion]: return super().potions + self.bought.potions
[docs] def card_delta(self) -> int: return super().card_delta() - len(self.purged)
[docs] class EventMerchant(Merchant): room_type = "Unknown (Merchant)" map_icon = "event_shop.png"
class Courier(NodeData): room_type = "Courier (Spire with Friends)" map_icon = "event.png" def get_description(self, to_append: dict[int, list[str]]): to_append[99].append("This is a Courier node. I don't know how to deal with it.") class Empty(NodeData): room_type = "Empty (Spire with Friends)" map_icon = "event.png" def get_description(self, to_append: dict[int, list[str]]) : to_append[99].append("This is an empty node. Nothing happened here.") class SWF(NodeData): room_type = "Unknown Node (Spire with Friends)" map_icon = "event.png" def get_description(self, to_append: dict[int, list[str]]): to_append[99].append("This is some Spire with Friends stuff. I don't know how to deal with it.")
[docs] class Campfire(NodeData): room_type = "Rest Site" map_icon = "rest.png" def __init__(self, parser: FileParser, floor: int, *extra): super().__init__(parser, floor, *extra) self._key = None self._data = None for rest in parser._data[parser.prefix + "campfire_choices"]: if rest["floor"] == floor: self._key = rest["key"] self._data = rest.get("data")
[docs] def get_description(self, to_append: dict[int, list[str]]) -> str: to_append[6].append(self.action)
@property def action(self) -> str: """Human-readable string of the action taken on this floor.""" match self._key: case "REST": return "Rested" case "RECALL": return "Got the Ruby key" case "SMITH": return f"Upgraded {get_card(self._data)}" case "LIFT": return f"Lifted for additional strength (Total: {self._data})" case "DIG": return "Dug!" case "PURGE": return f"Toked {get_card(self._data)}" case a: return f"Did {a!r} with {self._data!r}, but I'm not sure what this means"
[docs] class Boss(EncounterBase): room_type = "Boss" map_icon = "boss_node.png"
[docs] class BossChest(NodeData): room_type = "Boss Chest" map_icon = "boss_chest.png" end_of_act = True def __init__(self, parser: FileParser, floor: int, *extra): super().__init__(parser, floor, *extra) boss_relics = parser._get_boss_chest() picked = None skipped = [] if boss_relics.get("picked", "SKIP") != "SKIP": picked = get(boss_relics["picked"]) for relic in boss_relics["not_picked"]: skipped.append(get(relic)) self._picked = picked self._skipped = skipped @property def relics(self) -> list[Relic]: if self._picked: return [self._picked] return [] @property def skipped_relics(self) -> list[Relic]: return self._skipped
[docs] class Act4Transition(NodeData): room_type = "Transition into Act 4" map_icon = "event.png" end_of_act = True
[docs] class Victory(NodeData): room_type = "Victory!" map_icon = "event.png" def __init__(self, parser: FileParser, floor: int, *extra): super().__init__(parser, floor, *extra) self._score = parser._data.get("score", 0) self._data = parser._data.get("score_breakdown", [])
[docs] def get_description(self, to_append: dict[int, list[str]]): if self.score: to_append[6].extend(self.score_breakdown) to_append[6].append(f"Score: {self.score}")
@property def score(self) -> int: """How many points we got this run.""" return self._score @property def score_breakdown(self) -> list[str]: """Human-readable breakdown of the score.""" return self._data
[docs] class BottleRelic(NamedTuple): bottle_id: str #: The name of the bottle. card: SingleCard #: The card which is bottled.