"""Parsing of Slay the Spire 2 run history and savefile."""
from __future__ import annotations
import collections
import datetime
import urllib.parse
from src.nameinternal import get, get_card2, get_badge, Relic, Card, SingleCard, Potion
from src.config import config
from src.utils import format_for_slaytabase
from typing import Iterable, Generator, TYPE_CHECKING
if TYPE_CHECKING:
from src.typehints import ItemFloor
[docs]
class Player:
"""Hold game information for one player."""
def __init__(self, data: dict, parser: FileParser):
self._data = data
self._parser = parser
@property
def character(self) -> str:
"""Which character we're playing."""
try:
c: str = self._data["character"] # run history
except KeyError:
c: str = self._data["character_id"] # savefile
return c.partition(".")[2].title()
@property
def id(self) -> int:
try:
return self._data["id"]
except KeyError:
return self._data["net_id"]
@property
def relics(self) -> list[RelicData]:
"""The relics at run end/current node."""
ret = []
for rel in self._data["relics"]:
ret.append(RelicData(rel, self._parser))
return ret
@property
def deck(self):
"""The deck at run end/current node."""
return [get_card2(x) for x in self._data["deck"]]
@property
def badges(self):
return [Badge(x) for x in self._data.get("badges", ())]
def __eq__(self, value):
if not isinstance(value, Player):
return NotImplemented
return self._data == value._data
def __getattr__(self, name):
"""Backup to prevent crashing pages."""
return f"This has not yet been implemented ({self.__class__.__name__}.{name})"
[docs]
class FileParser:
"""Hold a single run (ongoing or not) data."""
game_version: int = 2 #: Which version of the game we care about.
done: bool = False #: Whether the run is over.
has_activemods = False #: Whether we know which mods are enabled.
def __init__(self, data: dict):
self._data = data
self._main_player_index: int | None = None
[docs]
def set_index(self, index: int | None):
"""Force a specific player index to be considered."""
if index is not None:
if not (0 <= index < len(self.players)):
raise IndexError(index)
self._main_player_index = index
[docs]
def get_main_player(self):
"""Return the player we care about, AKA the streamer."""
pl = self.players
if self._main_player_index is not None:
return pl[self._main_player_index]
if len(pl) == 1:
self._main_player_index = 0
return pl[0]
if config.server.steam_id:
for i, x in enumerate(pl):
if x.id == int(config.server.steam_id): # just in case
self._main_player_index = i
return x
return pl[0] # fallback
[docs]
def get_player_index(self):
"""Return the main player index.
This is such that `FP.players[FP.get_player_index()] == FP.get_main_player()`."""
if self._main_player_index is not None:
return self._main_player_index
pl = self.get_main_player()
try:
return self.players.index(pl)
except ValueError: # for some reason
# it's possible our call above set it
return self._main_player_index or 0
[docs]
def get_char_portrait(self):
c = self.character.lower()
# we do not currently account for losses
return f"/static/characters/{c}-portrait-3.png"
@property
def won(self) -> bool:
"""Whether or not we won the run."""
return False
@property
def seed(self) -> str:
try:
return self._data["seed"]
except KeyError:
return self._data["rng"]["seed"]
@property
def is_seeded(self) -> bool:
return False # temporary fix
@property
def character(self):
return self.get_main_player().character
@property
def deck(self):
return self.get_main_player().deck
@property
def relics(self):
return self.get_main_player().relics
@property
def relics_bare(self) -> list[Relic]:
return [x.relic for x in self.relics]
@property
def badges(self):
return self.get_main_player().badges
@property
def ascension_level(self) -> int:
"""Which Ascension level the run was played at."""
return self._data["ascension"]
@property
def players(self):
"""Read-only list of all players in this game (host first)."""
return [Player(x, self) for x in self._data["players"]]
@property
def current_hp_counts(self):
return [x.current_hp for x in self.path]
@property
def max_hp_counts(self):
return [x.max_hp for x in self.path]
@property
def gold_counts(self):
return [x.gold for x in self.path]
@property
def _removals(self) -> list[tuple[SingleCard, int]]:
res = []
for node in self.path:
for card in node.cards_removed:
res.append((card, node.floor))
return res
@property
def removals(self) -> Generator[ItemFloor, None, None]:
"""Every (name, floor) tuple of removed card and their floor."""
for card, floor in self._removals:
yield (card.name, floor)
[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):
return bool(self._removals)
[docs]
def get_cards(self) -> Generator[CardData, None, None]:
"""Yield each card with no repetition, with count."""
master_deck = self.deck
for card in set(master_deck):
yield CardData(card, master_deck)
@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 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]:
# I got these colors from screenshotting the game and using pipette
standard = "fff6e2" # eggshell-ish
upgraded = "7fff00" # green
enhanced = "ee82ee" # purple
text = (
'<a class="card" style="color:#{style}" href="https://raw.githubusercontent.com/OceanUwU/slaytabase/main/docs/{mod}/cards/{card_url}.png" target="_blank">'
'<svg width="48" height="40">' # the enhancement is slightly off-center, so we're making a slightly bigger SVG to account for it
'<image width="32" height="32" xlink:href="{website}/static/card2/Back_{color}.png"></image>'
'<image width="32" height="32" xlink:href="{website}/static/card2/Desc_{color}.png"></image>'
'<image width="32" height="32" xlink:href="{website}/static/card2/Type_{card.type_safe}.png"></image>'
'<image width="32" height="32" xlink:href="{website}/static/card2/Banner_{banner}.png"></image>'
'<image width="32" height="32" xlink:href="{website}/static/enhancements/{enhancement}.png" x=16 y=8></image>'
'</svg><span>{card.display_name}</span></a>'
)
def new_color() -> dict[str | None, list[CardData]]:
return {"Rare": [], "Uncommon": [], "Common": [], "Event": [], "Ancient": [], "Curse": [], "Quest": [], None: []}
order = ["ironclad", "silent", "regent", "necrobinder", "defect", "colorless", "curse"]
content = collections.defaultdict(new_color)
for card in cards:
color = card.color
if color == "event":
color = "colorless"
if color not in order:
order.insert(0, color)
content[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:
style = standard
if card.upgrades:
style = upgraded
if card.enhancement:
style = enhanced
enhancement = "empty"
if card.enhancement is not None:
enhancement = card.enhancement.internal.lower()
format_map = {
"style": style,
"color": color,
"website": config.server.url,
"banner": rarity or "Common",
"mod": urllib.parse.quote(card.card.mod or "2-slay the spire 2").lower(),
"card_url": format_for_slaytabase(card.card.internal),
"card": card,
"enhancement": enhancement,
}
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 path(self) -> list[PathNode]:
"""The path taken through the Spire."""
paths = []
i = 0
acts: list[str] = self._data["acts"]
act_names = []
if isinstance(acts[0], dict): # savefile
acts = [x["id"] for x in acts]
for a in acts:
n, _, name = a.partition(".")
assert n == "ACT", "An update has changed the Act definition"
act_names.append(name.title())
for act, name in zip(self._data.get("map_point_history", ()), act_names):
for node in act:
i += 1
paths.append(PathNode(self, node, i, name))
return paths
@property
def epoch(self) -> int:
"""Time in seconds since Jan 1, 1970."""
return self._data["start_time"] + self._data["run_time"]
@property
def timestamp(self) -> datetime.datetime:
"""Time when the run finished, as UTC."""
return datetime.datetime.fromtimestamp(self.epoch, datetime.UTC)
@property
def timedelta(self) -> datetime.timedelta:
"""Difference between now and the run."""
return datetime.datetime.now(datetime.UTC) - self.timestamp
@property
def modded(self):
return False # temporary
@property
def modifiers(self):
return self._data["modifiers"]
def __getattr__(self, name):
"""Backup to prevent crashing pages."""
return f"Not implemented ({self.__class__.__name__}.{name})"
[docs]
class Badge:
"""Run badges."""
def __init__(self, data: dict[str, str]):
self._data = data
self.title, self.description = get_badge(data)
@property
def name(self):
return self._data["id"].lower()
@property
def rarity(self):
return self._data["rarity"].lower()
[docs]
def as_html(self):
result = (
'<svg width="64" height="64">'
'<image width="64" height="64" xlink:href="{website}/static/badges/rarity_{self.rarity}.png"></image>'
'<image width="64" height="64" xlink:href="{website}/static/badges/{self.name}.png"></image>'
'<title>{self.title}\n{self.description}</title>'
'</svg>'
)
return result.format(website=config.server.url, self=self)
[docs]
class RelicData:
"""View relics and their information."""
def __init__(self, data: dict[str, str], parser: FileParser):
self.floor: int = data["floor_added_to_deck"] # works for both run and save
self.relic = get(data["id"])
self.props: dict | None = data.get("props")
self._parser = parser
@property
def name(self):
return self.relic.name
@property
def mod(self) -> str:
if not self.relic.mod:
# this is specifically for the github
return "2-slay the spire 2"
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"
[docs]
def description(self):
desc = [f"Obtained on floor {self.floor}", self.relic.description]
return "\n".join(desc)
[docs]
def escaped_description(self) -> str:
"""Escape the description for HTML display."""
escaped = self.description().replace("\n", "<br>").replace("'", "\\'")
char = self._parser.character.lower()
escaped = escaped.replace("[E]", f'<img src="/static/symbols/{char}_energy_2.png" alt="Energy symbol">')
escaped = escaped.replace("[STAR]", '<img src="/static/symbols/star.png" alt="Star symbol">')
return escaped
def __getattr__(self, name):
"""Backup to prevent crashing pages."""
return f"Not implemented ({self.__class__.__name__}.{name})"
[docs]
class CardData:
def __init__(self, card: str | SingleCard, cards_list: list[str]):
self.orig = card
if isinstance(card, str):
card = get_card2(card)
self._cards_list = list(cards_list)
self.single = card
self.card: Card = card.card
self.upgrades = card.upgrades
self.enhancement = card.enchantment
self.amount = card.amount
[docs]
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", "Curse", "Event", "Ancient", "Quest"):
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:
res = ""
match self.upgrades:
case 0:
res = self.card.name
case 1:
res = f"{self.card.name}+"
case a:
res = f"{self.card.name}+{a}"
if self.enhancement:
res = f"{res} @ {self.enhancement.name} {self.amount}"
return res
@property
def display_name(self) -> str:
if self.count > 1:
return f"{self.count}x {self.name}"
return self.name
[docs]
class PathNode:
def __init__(self, parser: FileParser, data: dict, floor: int, act_name: str = None):
self.parser = parser
self._data = data
self.floor = floor
self.act_name = act_name
self._set_room_data()
self._set_player_picks()
@property
def end_of_act(self) -> bool:
# this is a weird thing, but because of double boss, we don't wanna multiline it
return self.floor < 48 and self._data["map_point_type"] == "boss"
def _set_room_data(self):
"""Set the data specific to this node."""
# idk why rooms is a list, but there's only ever one item, even in mp
room: dict[str, str | int | list[str]] = self._data["rooms"][0]
self.turns_taken: int = room["turns_taken"]
#self.enemies: list[Enemy] = []
map_point = self._data["map_point_type"]
room_type = room["room_type"]
model_id: str | None = room.get("model_id")
self.name = ""
if model_id:
# TODO: just get the ID from... something
_, _, name = model_id.partition(".")
self.name = name.replace("_", " ").title()
#for m in room.get("monster_ids", ()):
# self.enemies.append(get(m))
match (map_point, room_type):
case ("ancient", "event"):
self.room_type = "Ancient"
case ("monster", "monster"):
self.room_type = "Enemy"
case ("unknown", "monster"):
self.room_type = "Enemy (unknown)"
case ("elite", "elite"):
self.room_type = "Elite fight"
case ("unknown", "event"):
self.room_type = "Event"
case ("rest_site", "rest_site"):
self.room_type = "Rest site"
case ("treasure", "treasure"):
self.room_type = "Treasure chest"
case ("shop", "shop"):
self.room_type = "Merchant"
case ("boss", "boss"):
self.room_type = "Boss fight"
def _set_player_picks(self):
"""Set all the player variables for this node."""
choices: dict[str] = self._data["player_stats"][self.parser.get_player_index()]
self.gold: int = choices["current_gold"]
self.gold_gained: int = choices["gold_gained"]
self.gold_lost: int = choices["gold_lost"]
self.gold_spent: int = choices["gold_spent"]
self.gold_stolen: int = choices["gold_stolen"]
self.damage_taken: int = choices["damage_taken"]
self.hp_healed: int = choices["hp_healed"]
self.current_hp: int = choices["current_hp"]
self.max_hp: int = choices["max_hp"]
self.max_hp_gained: int = choices["max_hp_gained"]
self.max_hp_lost: int = choices["max_hp_lost"]
self.rest_site_choices: list[str] = choices.get("rest_site_choices", [])
self.picked: list[SingleCard] = []
self.skipped: list[SingleCard] = []
self.cards_obtained: list[SingleCard] = []
self.cards_removed: list[SingleCard] = []
self.cards_transformed: list[SingleCard] = [] # XXX Nothing modifies this
self.cards_upgraded: list[SingleCard] = []
self.cards_enchanted: list[SingleCard] = []
self.relics: list[Relic] = []
self.relics_lost: list[Relic] = []
self.skipped_relics: list[Relic] = []
self.potions: list[Potion] = []
self.used_potions: list[Potion] = []
self.potions_from_alchemize: list[Potion] = []
self.potions_from_entropic: list[Potion] = []
self.discarded_potions: list[Potion] = []
self.skipped_potions: list[Potion] = []
for a in choices.get("ancient_choice", ()):
if not a["was_chosen"]:
self.skipped_relics.append(get(a["TextKey"]))
for c in choices.get("card_choices", ()):
card = get_card2(c["card"], self.floor)
if c["was_picked"]:
self.picked.append(card)
else:
self.skipped.append(card)
for g in choices.get("cards_gained", ()):
card = get_card2(g, self.floor)
if card not in self.picked:
self.cards_obtained.append(card)
#for l in choices.get("bought_colorless", ()):
# # XXX egg likely doesn't work here
# card = get(l)
# if card not in self.picked:
# self.cards_obtained.append(card)
for u in choices.get("upgraded_cards", ()):
self.cards_upgraded.append(get(u))
for e in choices.get("cards_enchanted", ()):
self.cards_enchanted.append(get_card2(e["card"]))
for m in choices.get("cards_removed", ()):
self.cards_removed.append(get_card2(m))
for r in choices.get("relic_choices", ()):
relic = get(r["choice"])
if r["was_picked"]:
self.relics.append(relic)
else:
self.skipped_relics.append(relic)
for p in choices.get("potion_choices", ()):
potion = get(p["choice"])
if p["was_picked"]:
self.potions.append(potion)
else:
self.skipped_potions.append(potion)
if self.name == "Thieving Hopper Weak": # don't remove+add a card here
both = set(self.cards_obtained) & set(self.cards_removed)
if len(both) == 1: # only care if there's exactly one card that matches
card = both.pop()
self.cards_obtained.remove(card)
self.cards_removed.remove(card)
@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
[docs]
def ancient_desc(self):
"""Ancient-specific description."""
choices: list[dict] = self._data["player_stats"][self.parser.get_player_index()]
if "ancient_choice" in choices:
picked: Relic | None = None
not_picked: list[Relic] = []
for d in choices["ancient_choice"]:
relic: Relic = get(d["title"]["key"])
if d["was_chosen"]:
picked = relic
else:
not_picked.append(relic)
if picked is not None:
return f"We picked {picked.name}, and skipped {' and '.join(x.name for x in not_picked)}."
return "No bonus picked."
[docs]
def description(self):
result = self.get_description()
final = []
desc = list(result.items())
desc.sort(key=lambda x: x[0])
for i, text in desc:
final.extend(text)
return "\n".join(final)
[docs]
def get_description(self):
to_append: dict[int, list[str]] = collections.defaultdict(list)
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)
if self.damage_taken:
to_append[2].append(f"{self.damage_taken} damage taken")
if self.turns_taken:
to_append[2].append(f"{self.turns_taken} turns")
if self.hp_healed:
to_append[4].append(f"Healed for {self.hp_healed}")
for action in self.rest_site_choices:
to_append[6].append(self.get_rest_action(action))
# 8 used to be Neow/Ancient bonus, but it's now superseded by relic gain/skip
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)
# used to skip from 20->30; relic entry now lowered to 24
if self.relics:
to_append[24].append("Relics obtained:")
to_append[24].extend(f"- {x.name}" for x in self.relics)
if self.skipped_relics:
to_append[26].append("Relics skipped:")
to_append[26].extend(f"- {x.name}" for x in self.skipped_relics)
if self.relics_lost:
to_append[28].append("Relics lost:")
to_append[28].extend(f"- {x.name}" for x in self.relics_lost)
# we skip a step when changing types, to allow for better extension if needed
if self.picked:
to_append[32].append("Picked:")
to_append[32].extend(f"- {x}" for x in self.picked)
if self.skipped:
to_append[34].append("Skipped:")
to_append[34].extend(f"- {x}" for x in self.skipped)
if self.cards_obtained:
to_append[36].append("Cards obtained:")
to_append[36].extend(f"- {x}" for x in self.cards_obtained)
if self.cards_upgraded:
to_append[38].append("Cards upgraded:")
to_append[38].extend(f"- {x}" for x in self.cards_upgraded)
if self.cards_enchanted:
to_append[40].append("Cards enchanted:")
to_append[40].extend(f"- {x}" for x in self.cards_enchanted)
if self.cards_transformed:
to_append[42].append("Cards transformed:")
to_append[42].extend(f"- {x}" for x in self.cards_transformed)
if self.cards_removed:
to_append[44].append("Cards removed:")
to_append[44].extend(f"- {x}" for x in self.cards_removed)
# a lot of these options have been moved around; notably, card info comes first now
if self.gold_gained:
to_append[44].append(f"Picked up {self.gold_gained} gold")
if self.gold_spent:
to_append[46].append(f"Spent {self.gold_spent} gold")
if self.gold_lost:
to_append[48].append(f"Lost {self.gold_lost} gold")
if self.gold_stolen:
to_append[50].append(f"Got {self.gold_stolen} gold stolen")
# and finally, health info
if self.max_hp_gained:
to_append[54].append(f"Gained {self.max_hp_gained} max HP")
if self.max_hp_lost:
to_append[56].append(f"Lost {self.max_hp_lost} max HP")
return to_append
[docs]
def escaped_description(self) -> str:
return self.description().replace("\n", "<br>").replace("'", "\\'")
[docs]
def get_rest_action(self, action: str) -> str:
match action:
case "HEAL":
return "Rested"
case "SMITH":
return "Upgraded a card"
case "MEND":
return "Mended an ally"
case "LIFT": # XXX can we figure out how much we lifted for?
return "Lifted for additional strength"
case "DIG":
return "Dug for a relic"
case "COOK": # and those
return "Cooked two cards for 9 max HP"
case "TOKE":
return "Toked a card"
case "CLONE":
return "Duplicated every clone"
case "HATCH":
return "Took good care of the baby bird and gained a new friend"
case "KINDLE":
return "Put some more fire into your Pumpkin Candle"
case a:
return f"Did {a!r}, but I'm not sure what this means"
@property
def map_icon(self):
"""The current page/run history map icon."""
room = self._data["rooms"][0]
on_map = self._data["map_point_type"]
actual = room["room_type"]
icon = actual
if on_map == "unknown" and actual != "event":
icon = f"unknown_{icon}"
elif on_map in ("ancient", "boss"):
m_id: str = room["model_id"]
p, _, name = m_id.partition(".")
icon = name.lower()
if icon in ("event", "shop"):
icon += "_2"
return f"{icon}.png"
def __getattr__(self, name):
"""Backup to prevent crashing pages."""
return f"Not implemented ({self.__class__.__name__}.{name})"