Skip to content

Commit 5eb35e5

Browse files
Add 11 advanced analytics modules: graveyard, ghost runs, behavior analysis, and more
New features (all optional — gracefully disabled if modules are absent): - Graveyard: procedural epitaphs for dead runs ("Had 4 potions. Used none.") - Ghost Run: speedrun-style splits against your best comparable run - Behavior Analysis: tilt detection, anti-pattern identification, decision quality - Spectral Deck Health: synergy graph connectivity scoring with orphan detection - Archetype Drift: track deck coherence floor by floor during a run - Pheromone Strategy Memory: track which strategies you use vs forget over time - Cascade Map: trace the downstream impact of any single card pick - Prophecy Engine: pre-run predictions based on historical performance - Hypothesis Lab: formally test strategic beliefs with Bayesian statistics - Rivalry Seed System: compare decisions on the same seed with another player - Hash-Chain Integrity: Merkle-verified run records for provable completion Integration: Graveyard page + nav link, autopsy section on run detail page, ghost splits on live tracker, all via try/except ImportError for graceful degradation when modules are absent.
1 parent 8ea5905 commit 5eb35e5

17 files changed

Lines changed: 1330 additions & 28 deletions

.gitignore

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,20 +38,9 @@ htmlcov/
3838
*.log
3939
spirescope logo.jpg
4040

41-
# Research modules (private, local-only)
41+
# Private modules (active investigation dependencies)
4242
sts2/risk.py
4343
sts2/diagnosis.py
44-
sts2/behavior.py
45-
sts2/spectral.py
46-
sts2/graveyard.py
47-
sts2/ghost.py
48-
sts2/drift.py
49-
sts2/pheromone.py
50-
sts2/cascade.py
51-
sts2/prophecy.py
52-
sts2/hypothesis.py
53-
sts2/rivalry.py
54-
sts2/integrity.py
5544
sts2/data/pheromones.json
5645
sts2/data/hypotheses.json
5746

sts2/behavior.py

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
"""Behavior analysis — tilt detection, anti-patterns, decision quality."""
2+
import math
3+
4+
5+
# ── Tilt Detection ──
6+
7+
def detect_tilt(runs, session_window_hours=4):
8+
"""Detect tilt from recent run patterns.
9+
10+
Returns momentum score (-100 to +100) and whether tilting.
11+
"""
12+
if len(runs) < 3:
13+
return {"tilting": False, "momentum": 0, "message": ""}
14+
15+
sessions = _group_sessions(runs, session_window_hours)
16+
current = sessions[-1] if sessions else runs[-5:]
17+
if not current:
18+
return {"tilting": False, "momentum": 0, "message": ""}
19+
20+
avg_floors = _avg_last_floor(current)
21+
hist_avg = _avg_last_floor(runs)
22+
consecutive_losses = _count_trailing_losses(current)
23+
durations = [r.run_time for r in current if r.run_time > 0]
24+
shortening = len(durations) > 2 and durations[-1] < durations[0] * 0.5
25+
26+
momentum = 0
27+
if hist_avg > 0 and avg_floors < hist_avg * 0.6:
28+
momentum -= 40
29+
if consecutive_losses >= 3:
30+
momentum -= 30
31+
if consecutive_losses >= 5:
32+
momentum -= 20
33+
if shortening:
34+
momentum -= 20
35+
if current[-1].win:
36+
momentum += 30
37+
if len(current) <= 2:
38+
momentum += 10 # Short session, no pattern yet
39+
40+
message = ""
41+
if momentum < -40:
42+
message = (
43+
f"Your last {len(current)} runs averaged floor {avg_floors:.0f}. "
44+
f"Your overall average is floor {hist_avg:.0f}. Consider taking a break."
45+
)
46+
47+
return {
48+
"tilting": momentum < -40,
49+
"momentum": momentum,
50+
"consecutive_losses": consecutive_losses,
51+
"session_avg_floor": round(avg_floors, 1),
52+
"historical_avg_floor": round(hist_avg, 1),
53+
"session_size": len(current),
54+
"message": message,
55+
}
56+
57+
58+
def _group_sessions(runs, window_hours):
59+
"""Group runs into sessions based on timestamp proximity."""
60+
if not runs:
61+
return []
62+
sorted_runs = sorted(runs, key=lambda r: r.timestamp)
63+
sessions = [[sorted_runs[0]]]
64+
for r in sorted_runs[1:]:
65+
if r.timestamp - sessions[-1][-1].timestamp < window_hours * 3600:
66+
sessions[-1].append(r)
67+
else:
68+
sessions.append([r])
69+
return sessions
70+
71+
72+
def _avg_last_floor(runs):
73+
floors = [r.floors[-1].floor for r in runs if r.floors]
74+
return sum(floors) / len(floors) if floors else 0
75+
76+
77+
def _count_trailing_losses(runs):
78+
count = 0
79+
for r in reversed(runs):
80+
if r.win:
81+
break
82+
count += 1
83+
return count
84+
85+
86+
# ── Anti-Pattern Detection ──
87+
88+
def detect_anti_patterns(runs):
89+
"""Scan run history for named recurring mistakes."""
90+
if len(runs) < 5:
91+
return []
92+
93+
patterns = []
94+
losses = [r for r in runs if not r.win]
95+
wins = [r for r in runs if r.win]
96+
97+
# The Hoarder — dying with unused potions
98+
hoard_count = 0
99+
for r in losses:
100+
gained = sum(len(f.potions_gained) for f in r.floors)
101+
used = sum(len(f.potions_used) for f in r.floors)
102+
if gained >= 2 and used == 0:
103+
hoard_count += 1
104+
if hoard_count >= 3:
105+
patterns.append({
106+
"name": "The Hoarder",
107+
"description": f"You died with unused potions in {hoard_count} of {len(losses)} losses.",
108+
"severity": "warning",
109+
"stat": f"{hoard_count}/{len(losses)} deaths",
110+
})
111+
112+
# The Greedy Builder — losing decks are larger
113+
if losses:
114+
avg_loss_size = sum(len(r.deck) for r in losses) / len(losses)
115+
if wins:
116+
avg_win_size = sum(len(r.deck) for r in wins) / len(wins)
117+
if avg_loss_size > avg_win_size * 1.3:
118+
patterns.append({
119+
"name": "The Greedy Builder",
120+
"description": f"Your losing decks average {avg_loss_size:.0f} cards vs {avg_win_size:.0f} in wins.",
121+
"severity": "warning",
122+
"stat": f"{avg_loss_size:.0f} vs {avg_win_size:.0f} cards",
123+
})
124+
elif avg_loss_size > 30:
125+
patterns.append({
126+
"name": "The Greedy Builder",
127+
"description": f"Your decks average {avg_loss_size:.0f} cards. Tighter decks draw key cards more often.",
128+
"severity": "info",
129+
"stat": f"avg {avg_loss_size:.0f} cards",
130+
})
131+
132+
# The Coward — skipping elites
133+
total_elite_floors = 0
134+
total_combat_floors = 0
135+
for r in runs:
136+
for f in r.floors:
137+
if f.type in ("monster", "elite", "boss"):
138+
total_combat_floors += 1
139+
if f.type == "elite":
140+
total_elite_floors += 1
141+
if total_combat_floors > 20:
142+
elite_rate = total_elite_floors / total_combat_floors
143+
if elite_rate < 0.08:
144+
patterns.append({
145+
"name": "The Coward",
146+
"description": f"Only {elite_rate*100:.0f}% of your fights are elites. Elites give relics that carry runs.",
147+
"severity": "info",
148+
"stat": f"{elite_rate*100:.0f}% elite rate",
149+
})
150+
151+
# Potion Paralysis — dying with potions in hand (different from hoarder: checks final state)
152+
potions_at_death = 0
153+
deaths_checked = 0
154+
for r in losses:
155+
gained = sum(len(f.potions_gained) for f in r.floors)
156+
used = sum(len(f.potions_used) for f in r.floors)
157+
remaining = gained - used
158+
if remaining >= 2:
159+
potions_at_death += 1
160+
deaths_checked += 1
161+
if deaths_checked > 5 and potions_at_death / deaths_checked > 0.3:
162+
patterns.append({
163+
"name": "Potion Paralysis",
164+
"description": f"You die with 2+ potions in {potions_at_death} of {deaths_checked} losses ({potions_at_death*100//deaths_checked}%).",
165+
"severity": "warning",
166+
"stat": f"{potions_at_death}/{deaths_checked} deaths",
167+
})
168+
169+
return patterns
170+
171+
172+
# ── Decision Quality ──
173+
174+
def decision_quality_profile(run, kb=None):
175+
"""Classify decision-making style using consistency and diversity metrics."""
176+
decisions = _encode_decisions(run, kb)
177+
if len(decisions) < 10:
178+
return {"classification": "insufficient_data", "consistency": 0, "diversity": 0}
179+
180+
consistency = _consistency_index(decisions)
181+
diversity = _diversity_score(decisions, m=2)
182+
183+
if consistency > 1.2:
184+
classification = "formulaic"
185+
elif consistency < 0.4:
186+
classification = "chaotic"
187+
elif diversity < 0.3:
188+
classification = "rigid"
189+
elif diversity > 1.2:
190+
classification = "adaptive"
191+
else:
192+
classification = "strategic"
193+
194+
return {
195+
"classification": classification,
196+
"consistency": round(consistency, 3),
197+
"diversity": round(diversity, 3),
198+
}
199+
200+
201+
def _encode_decisions(run, kb):
202+
"""Encode run decisions as a numeric series for analysis."""
203+
series = []
204+
for f in run.floors:
205+
# Encode floor type as base signal
206+
type_scores = {"monster": 1, "elite": 3, "boss": 5, "shop": 2,
207+
"rest_site": 2, "event": 1, "treasure": 1, "unknown": 1}
208+
score = type_scores.get(f.type, 1)
209+
210+
# Add card pick diversity signal
211+
if f.card_picked and kb:
212+
card = kb.get_card_by_id(f.card_picked)
213+
if card:
214+
type_bonus = {"Attack": 1, "Skill": 2, "Power": 3}.get(card.type, 0)
215+
score += type_bonus
216+
217+
# Add damage signal (normalized)
218+
if f.damage_taken > 0 and f.max_hp > 0:
219+
score += int(f.damage_taken / f.max_hp * 5)
220+
221+
series.append(score)
222+
return series
223+
224+
225+
def _consistency_index(series):
226+
"""Measure how consistent decision patterns are across the run (pure Python)."""
227+
n = len(series)
228+
if n < 10:
229+
return 0.5
230+
mean = sum(series) / n
231+
devs = [x - mean for x in series]
232+
cumdev = []
233+
s = 0
234+
for d in devs:
235+
s += d
236+
cumdev.append(s)
237+
R = max(cumdev) - min(cumdev)
238+
S = (sum(d * d for d in devs) / n) ** 0.5
239+
if S == 0 or R == 0:
240+
return 0.5
241+
return math.log(R / S) / math.log(n)
242+
243+
244+
def _diversity_score(series, m=2):
245+
"""Measure decision diversity — how varied the play patterns are (pure Python)."""
246+
n = len(series)
247+
if n < m + 2:
248+
return 0
249+
r = 0.2 * _std(series)
250+
if r == 0:
251+
return 0
252+
253+
def count_matches(template_len):
254+
count = 0
255+
for i in range(n - template_len):
256+
for j in range(i + 1, n - template_len):
257+
if all(abs(series[i + k] - series[j + k]) <= r for k in range(template_len)):
258+
count += 1
259+
return count
260+
261+
A = count_matches(m)
262+
B = count_matches(m + 1)
263+
if A == 0 or B == 0:
264+
return 0
265+
return -math.log(B / A)
266+
267+
268+
def _std(series):
269+
n = len(series)
270+
if n < 2:
271+
return 0
272+
mean = sum(series) / n
273+
return (sum((x - mean) ** 2 for x in series) / n) ** 0.5

sts2/cascade.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Cascade Map — trace the downstream impact of a single card pick."""
2+
3+
4+
def trace_card_impact(run, card_id, kb):
5+
"""Trace the downstream impact of picking a specific card.
6+
7+
Compares pre-pick vs post-pick performance metrics:
8+
damage taken, fight length, HP trajectory.
9+
"""
10+
# Find when the card was picked
11+
pick_floor = None
12+
for floor in run.floors:
13+
if floor.card_picked == card_id:
14+
pick_floor = floor.floor
15+
break
16+
17+
if pick_floor is None:
18+
return {"error": "Card not found in run floor history"}
19+
20+
pre = [f for f in run.floors if f.floor < pick_floor]
21+
post = [f for f in run.floors if f.floor >= pick_floor]
22+
23+
pre_combats = [f for f in pre if f.type in ("monster", "elite", "boss") and f.turns > 0]
24+
post_combats = [f for f in post if f.type in ("monster", "elite", "boss") and f.turns > 0]
25+
26+
pre_avg_dmg = sum(f.damage_taken for f in pre_combats) / len(pre_combats) if pre_combats else 0
27+
post_avg_dmg = sum(f.damage_taken for f in post_combats) / len(post_combats) if post_combats else 0
28+
29+
pre_avg_turns = sum(f.turns for f in pre_combats) / len(pre_combats) if pre_combats else 0
30+
post_avg_turns = sum(f.turns for f in post_combats) / len(post_combats) if post_combats else 0
31+
32+
card = kb.get_card_by_id(card_id) if kb else None
33+
34+
return {
35+
"card_name": card.name if card else card_id,
36+
"card_id": card_id,
37+
"picked_floor": pick_floor,
38+
"floors_survived_after": len(post),
39+
"total_floors": len(run.floors),
40+
"damage_delta": round(post_avg_dmg - pre_avg_dmg, 1),
41+
"turns_delta": round(post_avg_turns - pre_avg_turns, 1),
42+
"pre_avg_damage": round(pre_avg_dmg, 1),
43+
"post_avg_damage": round(post_avg_dmg, 1),
44+
"pre_avg_turns": round(pre_avg_turns, 1),
45+
"post_avg_turns": round(post_avg_turns, 1),
46+
"impact": "positive" if post_avg_dmg < pre_avg_dmg else "negative" if post_avg_dmg > pre_avg_dmg else "neutral",
47+
}
48+
49+
50+
def trace_all_picks(run, kb):
51+
"""Trace impact of every card picked during the run."""
52+
results = []
53+
seen = set()
54+
for floor in run.floors:
55+
if floor.card_picked and floor.card_picked not in seen:
56+
seen.add(floor.card_picked)
57+
result = trace_card_impact(run, floor.card_picked, kb)
58+
if "error" not in result:
59+
results.append(result)
60+
return sorted(results, key=lambda r: r["picked_floor"])

0 commit comments

Comments
 (0)