|
| 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 |
0 commit comments