SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
chart.py
Go to the documentation of this file.
1"""Mermaid flowcharts for the combined document.
2
3Two charts, and they mean two different things, which is exactly why they are two
4charts. The JOURNEY chart is real-world time: which game you played, in what order,
5by file date. A RUN chart is save lineage inside one character: which snapshot came
6from which, by what progress it contains. Drawing both in one picture would leave an
7arrow meaning "later that month" in one place and "reloaded and forked" in another.
8
9Every node is one .sl2 file, referred to by its number in the document's reference
10list rather than its name — a filename in a box makes the box wider than the chart.
11"""
12
13from datetime import datetime
14
15from .timeline import achievements, children
16
17
18## @brief Mermaid takes the label between double quotes, so the label may not contain
19# one; `#quot;` is its own escape. Line breaks inside a node are `<br/>`.
20# (Both are backticked so Doxygen reads them as literals — bare `#quot;` is a link
21# request to it, and a bare break tag is parsed as HTML.)
22def mm(text):
23 return str(text).replace('"', "#quot;")
24
25
26def label(lines):
27 return mm("<br/>".join(line for line in lines if line))
28
29
30## @brief "1 boss" / "2 bosses". A fresh save really does hold one of things, and a
31# count that reads "1 bosses" makes the whole document look generated.
32def plural(n, word):
33 end = "" if n == 1 else ("es" if word.endswith(("s", "x", "ch")) else "s")
34 return f"{n} {word}{end}"
35
36
37##
38# @brief Format a play time as H:MM:SS, or an em dash when the game does not store one.
39def hms(sec):
40 if not sec:
41 return "—"
42 h, r = divmod(int(sec), 3600)
43 m, s = divmod(r, 60)
44 return f"{h}:{m:02d}:{s:02d}"
45
46
47##
48# @brief The number a game counts progress in, for a snapshot's label or table cell.
49# @details Every game but one levels up, so "lv95" is the obvious shorthand — but
50# Sekiro has no level at all, and printing "lv0" for it would state a field the game
51# does not have. Its equivalent is Attack Power, which only ever climbs (one point per
52# Memory consumed), so that is what its charts and tables count in.
53# @param r A snapshot. @return e.g. @c "lv95" or @c "atk 7".
54def rank(r):
55 if r["game"] == "sdt":
56 return "atk —" if r["attack"] is None else f"atk {r['attack']}"
57 return f"lv{r['level']}"
58
59
60## @brief The same value bare, for a table cell whose column is already labelled.
61def rank_cell(r):
62 return (
63 "—"
64 if r["game"] == "sdt" and r["attack"] is None
65 else str(r["attack"] if r["game"] == "sdt" else r["level"])
66 )
67
68
69## @brief Header for that column, given the run's game.
70def rank_label(game):
71 return "Atk" if game == "sdt" else "Lv"
72
73
74##
75# @brief One run's snapshot tree, as a Mermaid flowchart.
76# @details One node per save file, labelled with its reference number, the level it was
77# at, and what it achieved that its parent had not. A node that achieved nothing still
78# appears — it is a real save and its file is real — but it says so with nothing rather
79# than with filler.
80# @param rows The run's snapshots in order.
81# @param parents Parent index per row, @param restarts rows that start a fresh line.
82# @param refs {filename: reference number}.
83# @param theme Game id, used to colour the ending/leaf nodes.
84# @return A list of Markdown lines, fenced as a mermaid block.
85def run_chart(rows, parents, restarts, refs, theme=None):
86 L = ["```mermaid", "flowchart TD"]
87 kids = children(parents)
88 for i, r in enumerate(rows):
89 prev = rows[parents[i]] if parents[i] is not None else None
90 head = f"^{refs.get(r['path'], '?')} · {hms(r['play_time'])}"
91 if r["slot"] and any(x["slot"] != r["slot"] for x in rows):
92 head += f" · slot {r['slot']}"
93 lines = [head, rank(r)]
94 if i in restarts:
95 lines.insert(1, "SEPARATE LINE")
96 body = label(lines + achievements(r, prev))
97 L.append(f' n{i}["{body}"]')
98 for i, p in enumerate(parents):
99 if p is not None:
100 L.append(f" n{p} --> n{i}")
101 # A leaf is where a line stopped; an ending is where it FINISHED. Both are worth
102 # seeing at a glance, and an ending outranks a leaf when a node is both.
103 ends = [
104 i
105 for i, r in enumerate(rows)
106 if set(r["endings"])
107 - (set(rows[parents[i]]["endings"]) if parents[i] is not None else set())
108 ]
109 leaves = [i for i in range(len(rows)) if i not in kids and i not in ends]
110 if ends:
111 L.append(
112 " classDef ending fill:#3a2a12,stroke:#c9a227,color:#f0e6d2,stroke-width:2px;"
113 )
114 L.append(" class " + ",".join(f"n{i}" for i in ends) + " ending;")
115 if leaves:
116 L.append(" classDef leaf stroke-dasharray:4 3;")
117 L.append(" class " + ",".join(f"n{i}" for i in leaves) + " leaf;")
118 if restarts:
119 L.append(" classDef restart stroke:#9a3b3b,stroke-width:2px;")
120 L.append(" class " + ",".join(f"n{i}" for i in sorted(restarts)) + " restart;")
121 L.append("```")
122 return L
123
124
125##
126# @brief The cross-game journey: one node per run, in the order they were played.
127# @details Ordered and linked by FILE DATE, because that is the only clock shared
128# across games — a Dark Souls II play time and a Dark Souls III one are unrelated
129# numbers. Each node carries the run's span in files and where it got to, so the chart
130# answers "what have I actually played" on its own.
131# @param runs {(game, name): [snapshot, ...]}, @param refs {filename: number}.
132def journey_chart(runs, refs):
133 L = ["```mermaid", "flowchart LR"]
134 items = list(runs.items())
135 for n, ((_game, name, _slot), rows) in enumerate(items):
136 last = rows[-1]
137 nums = sorted({refs.get(r["path"], 0) for r in rows})
138 span = f"^{nums[0]}" if len(nums) == 1 else f"^{nums[0]}–^{nums[-1]}"
139 got = [
140 f"{len(rows)} save{'' if len(rows) == 1 else 's'} · {span}",
141 f"{rank(last)} · {hms(last['play_time'])}",
142 ]
143 # The carried set when the run section worked one out — a boss whose soul was
144 # spent is still a boss killed, and the journey chart should say so.
145 known = last.get("carried_bosses") or last["bosses"]
146 if known:
147 got.append(plural(len(known), "boss"))
148 if last["endings"]:
149 got.append("FINISHED: " + " · ".join(sorted(last["endings"])))
150 body = label(["{} — {}".format(last["title"], name)] + got)
151 L.append(f' r{n}["{body}"]')
152 for n in range(1, len(items)):
153 L.append(f" r{n - 1} --> r{n}")
154 L.append("```")
155 return L
156
157
158##
159# @brief The reference list every chart node points at.
160# @details Wiki-style [[links]] because that is what the owner reads these in, and
161# ordered earliest to latest by file date so the numbering itself carries the history.
162def reference_list(order):
163 L = [
164 "## References",
165 "",
166 "_Every node above is one save file. Numbered earliest to latest by file date._",
167 "",
168 ]
169 for num, name, mtime, _path in order:
170 when = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
171 L.append(f"^{num}: [[{name}]] — _{when}_")
172 return L + [""]
label(lines)
Definition chart.py:26
mm(text)
Mermaid takes the label between double quotes, so the label may not contain one; #quot; is its own es...
Definition chart.py:22