diff --git a/COURSE_MAP.md b/COURSE_MAP.md index d58db03..68b4ffc 100644 --- a/COURSE_MAP.md +++ b/COURSE_MAP.md @@ -156,6 +156,18 @@ External data is untrusted. Validate at boundaries, isolate persistence from dom --- +## Data Recall Checkpoint + +**Prerequisite:** Level 3 complete + +You learned NumPy and Pandas in Level 1. The RPG now has a real, validated +combat log (Level 3) — use those data skills again before moving on. +No new concepts, 30–60 minutes. + +**Checkpoint:** [Combat Log Analytics](data_recall_checkpoint/README.md) + +--- + ## Level 4: Interfaces and Reports **Prerequisite:** Level 3 complete diff --git a/README.md b/README.md index a13eff2..18b3e88 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,9 @@ level_3_validation_and_persistence/ ← Pydantic, repository pattern, SQLite missions/ ← 7 missions projects/ ← P01 Validated Bestiary · P02 Save Slot Manager · P03 boss fight +data_recall_checkpoint/ ← mandatory checkpoint: NumPy/Pandas recall on the real combat log + (no new concepts — 30-60 min, see COURSE_MAP.md) + level_4_interfaces/ ← CLI (Typer), logging, Rich output, reports, entry points starter_verbose_rpg/ ← the interface-smelly app you will improve missions/ ← 6 missions @@ -97,6 +100,9 @@ level_7_concurrency_and_background_work/ ← asyncio, job_id, ThreadPoolExecuto Each checkpoint project (`P01`, `P02`) is a standalone exercise that exercises the level's concepts. The boss fight (`P03`) is a full-size challenge that combines everything from the level. +The `data_recall_checkpoint/` between Level 3 and Level 4 is mandatory, not optional — the course +later ties data skills back together once more, after Level 7. + ## Note on global commands This repository contains intentionally incomplete starter files. Running diff --git a/data_recall_checkpoint/README.md b/data_recall_checkpoint/README.md new file mode 100644 index 0000000..64be547 --- /dev/null +++ b/data_recall_checkpoint/README.md @@ -0,0 +1,96 @@ +# Data Recall Checkpoint: Combat Log Analytics + +You learned NumPy and Pandas back in Level 1. Since then the RPG has gained +validation (Pydantic) and persistence (the repository pattern) in Level 3 — +including a real, validated combat log. Before moving into Level 4's +interfaces and tooling, use those data skills again on the combat logs your +application now knows how to store. + +This is **recall, not a new lesson**. No new concepts. No new library. +30–60 minutes. + +## The model + +Level 3 Mission 06 introduced `CombatLogRow`: one row per **turn**, not per +battle. A single battle (`battle_id`) usually spans several rows — all but +the last have `result == "ongoing"`; only the final row of a battle has a +terminal `result` (`"win"` or `"loss"`). To compute a win rate you need +exactly the terminal rows, not every row. + +## The data + +`data/combat_log_fixture.csv` — ~220 rows, same schema `CsvCombatLogRepository` +writes: + +``` +battle_id, turn, hero_name, hero_class, monster, action, +damage_dealt, damage_taken, hero_hp, monster_hp, result +``` + +`action` is `"attack"` or `"potion"`. `result` is `"ongoing"`, `"win"`, or +`"loss"`. You don't need to generate this data yourself — it's provided so +grading is stable regardless of how much you've personally played. + +A second file, `combat_log_fixture_variant.csv`, has the same shape but +different numbers (including a different monster difficulty ranking) — the +checker uses it to make sure your answers actually come from the data, not +from a number you typed once and left there. + +## Cheat-sheet (you've seen all of this in Level 1) + +```python +df = pd.read_csv(path) # M21 +df[df["col"] != "x"] # M22 — filter +df.groupby("col")["other"].apply(fn) # M22 — groupby +df.sort_values("col") # M22 — sort/rank +arr = df["col"].to_numpy() # M17 — array +arr.mean(), arr.std(), np.percentile(arr, 75) # M20 — aggregations +arr > threshold # M18 — boolean mask +``` + +Relevant Level 1 missions if you want a refresher: [17 — Dice Become Arrays](../level_1_python_basics/missions/17_dice_become_arrays/README.md), [18 — Critical Hits](../level_1_python_basics/missions/18_critical_hits/README.md), [20 — Damage Distributions](../level_1_python_basics/missions/20_damage_distributions/README.md), [21 — From Combat Log to DataFrame](../level_1_python_basics/missions/21_from_combat_log_to_dataframe/README.md), [22 — Filter, Group, Rank](../level_1_python_basics/missions/22_filter_group_rank/README.md). + +## Your task + +Open `task.py` and implement all four functions. + +```python +def load_combat_log(path: Path) -> pd.DataFrame: ... +def win_rate_by_class(df: pd.DataFrame) -> dict[str, float]: ... +def damage_stats(df: pd.DataFrame) -> dict[str, float | int]: ... +def rank_monsters_by_difficulty(df: pd.DataFrame) -> list[str]: ... +``` + +Each function's docstring has the exact contract. Two notes worth reading +before you start: + +- **`win_rate_by_class` must use pandas' `groupby`.** A per-class Python + loop can produce the same numbers, but `groupby` is specifically what + this task is here to reactivate — the checker looks for it. +- **`damage_stats` only counts `"attack"` rows.** A `"potion"` turn has + `damage_dealt == 0` because the hero healed instead of attacking, not + because the hit was weak — mixing those zeros into the stats would + understate real attack damage. + +## Questions this answers + +- Which hero class wins most often, and by how much? +- What does a "hard-hitting" attack look like in this log (p75), and how + often does one land? +- Which monster is operationally the hardest, by this log's win rates alone? + +## How to check + +```bash +uv run python data_recall_checkpoint/check.py +``` + +The checker runs every function against two different fixtures and checks +the actual values — not your code's syntax. It also confirms `groupby` is +really used in `win_rate_by_class` and that `np.percentile` is really called +in `damage_stats`, since those two specific APIs are the point of this +checkpoint. + +--- + +**Next:** [Level 4 — Interfaces and Reports](../level_4_interfaces/README.md) diff --git a/data_recall_checkpoint/check.py b/data_recall_checkpoint/check.py new file mode 100644 index 0000000..77117a8 --- /dev/null +++ b/data_recall_checkpoint/check.py @@ -0,0 +1,226 @@ +"""Check: Data Recall Checkpoint — Combat Log Analytics. + +Behavioral checker: every function is run against TWO fixtures (primary and +variant) and compared against ground truth computed independently with +stdlib `csv` + plain Python — never by re-running the student's own pandas +or NumPy code. The variant fixture has a genuinely different win-rate +ranking and different damage statistics, so a hardcoded answer that happens +to match the primary fixture fails on the variant. + +For win_rate_by_class and damage_stats, Pandas' `groupby` and NumPy's array +work are explicit learning objectives for this checkpoint (not just "produce +the right numbers") — so those two functions also get a light technique +gate on top of the behavioral one: an AST check that `groupby` is actually +called inside win_rate_by_class, and a monkeypatch spy confirming +`numpy.percentile` is actually called inside damage_stats. Neither gate +cares about variable names, chaining style, or lambda usage. +""" +import ast +import csv +import json +import math +import statistics +import sys +from collections import defaultdict +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +mission = Path(__file__).parent +PROGRESS_FILE = Path(__file__).parent / ".progress" + + +def update_progress() -> None: + progress: dict = {"missions": {}, "projects": {}} + if PROGRESS_FILE.exists(): + try: + progress = json.loads(PROGRESS_FILE.read_text()) + except json.JSONDecodeError: + pass + progress["projects"]["data_recall_checkpoint"] = "complete" + PROGRESS_FILE.write_text(json.dumps(progress, indent=2)) + + +# ── ground truth (stdlib csv + plain Python only — never pandas/numpy) ────── + +def _load_raw_rows(path: Path) -> list[dict]: + with path.open(newline="") as f: + return list(csv.DictReader(f)) + + +def _ground_truth_win_rate_by(rows: list[dict], key: str) -> dict[str, float]: + totals: dict[str, list[int]] = defaultdict(lambda: [0, 0]) + for row in rows: + if row["result"] in ("win", "loss"): + totals[row[key]][1] += 1 + if row["result"] == "win": + totals[row[key]][0] += 1 + return {k: wins / total for k, (wins, total) in totals.items() if total > 0} + + +def _percentile_linear(sorted_values: list[float], pct: float) -> float: + n = len(sorted_values) + if n == 0: + return 0.0 + k = (n - 1) * (pct / 100) + f = int(k) + c = f + 1 if f + 1 < n else f + if f == c: + return float(sorted_values[f]) + d0 = sorted_values[f] * (c - k) + d1 = sorted_values[c] * (k - f) + return float(d0 + d1) + + +def _ground_truth_damage_stats(rows: list[dict]) -> dict[str, float | int]: + values = [int(row["damage_dealt"]) for row in rows if row["action"] == "attack"] + mean = statistics.mean(values) + std = statistics.pstdev(values) + p75 = _percentile_linear(sorted(values), 75) + high_count = sum(1 for v in values if v > p75) + return {"mean": mean, "std": std, "p75": p75, "high_damage_count": high_count} + + +def _ground_truth_ranking(rows: list[dict]) -> list[str]: + rates = _ground_truth_win_rate_by(rows, "monster") + return [name for name, _ in sorted(rates.items(), key=lambda kv: kv[1])] + + +def _dicts_close(actual: dict, expected: dict, *, rel_tol: float = 1e-6, abs_tol: float = 1e-6) -> bool: + if set(actual.keys()) != set(expected.keys()): + return False + for k, exp_v in expected.items(): + act_v = actual.get(k) + if isinstance(exp_v, float) or isinstance(act_v, float): + if act_v is None or not math.isclose(float(act_v), float(exp_v), rel_tol=rel_tol, abs_tol=abs_tol): + return False + else: + if act_v != exp_v: + return False + return True + + +# ── AST technique gate: win_rate_by_class must call .groupby(...) ─────────── + +def _function_uses_attr_call(source: str, fn_name: str, attr_name: str) -> bool: + tree = ast.parse(source) + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef) or node.name != fn_name: + continue + for child in ast.walk(node): + if isinstance(child, ast.Call) and isinstance(child.func, ast.Attribute): + if child.func.attr == attr_name: + return True + return False + + +FIXTURES = [ + ("primary", mission / "data" / "combat_log_fixture.csv"), + ("variant", mission / "data" / "combat_log_fixture_variant.csv"), +] + + +def main() -> None: + try: + from task import ( # type: ignore[import] + load_combat_log, + win_rate_by_class, + damage_stats, + rank_monsters_by_difficulty, + ) + except ImportError as exc: + print(f"❌ Cannot import from task.py: {exc}") + raise SystemExit(1) + + import pandas as pd + + # ── scaffold guard ──────────────────────────────────────────────────── + try: + load_combat_log(FIXTURES[0][1]) + except NotImplementedError: + print("❌ load_combat_log is not implemented yet — replace NotImplementedError with your code") + raise SystemExit(1) + except Exception: + pass # any real error = student modified it; continue to the real checks + + for fixture_name, fixture_path in FIXTURES: + raw_rows = _load_raw_rows(fixture_path) + + # ── Task 1: load_combat_log ───────────────────────────────────── + df = load_combat_log(fixture_path) + if not isinstance(df, pd.DataFrame): + print(f"❌ [{fixture_name}] load_combat_log must return a pandas DataFrame, got {type(df)}") + raise SystemExit(1) + if len(df) != len(raw_rows): + print(f"❌ [{fixture_name}] load_combat_log: expected {len(raw_rows)} rows, got {len(df)}") + raise SystemExit(1) + expected_columns = { + "battle_id", "turn", "hero_name", "hero_class", "monster", + "action", "damage_dealt", "damage_taken", "hero_hp", "monster_hp", "result", + } + if not expected_columns.issubset(set(df.columns)): + print(f"❌ [{fixture_name}] load_combat_log: missing columns " + f"{expected_columns - set(df.columns)}") + raise SystemExit(1) + if not pd.api.types.is_integer_dtype(df["damage_dealt"]): + print(f"❌ [{fixture_name}] load_combat_log: 'damage_dealt' should be numeric " + f"(did you read the CSV with pandas, not row-by-row strings?)") + raise SystemExit(1) + print(f"✓ [{fixture_name}] load_combat_log → DataFrame, {len(df)} rows, correct columns") + + # ── Task 2: win_rate_by_class (behavior + groupby technique gate) ─ + result = win_rate_by_class(df) + expected = _ground_truth_win_rate_by(raw_rows, "hero_class") + if not isinstance(result, dict) or not _dicts_close(result, expected): + print(f"❌ [{fixture_name}] win_rate_by_class: expected {expected}, got {result}") + raise SystemExit(1) + print(f"✓ [{fixture_name}] win_rate_by_class → {result}") + + # ── Task 3: damage_stats (behavior + np.percentile technique gate) ─ + import numpy as np + _percentile_calls: list = [] + _original_percentile = np.percentile + + def _percentile_spy(*args, **kwargs): + _percentile_calls.append((args, kwargs)) + return _original_percentile(*args, **kwargs) + + np.percentile = _percentile_spy + try: + result = damage_stats(df) + finally: + np.percentile = _original_percentile + + expected = _ground_truth_damage_stats(raw_rows) + if not isinstance(result, dict) or not _dicts_close(result, expected, rel_tol=1e-3, abs_tol=1e-3): + print(f"❌ [{fixture_name}] damage_stats: expected {expected}, got {result}") + raise SystemExit(1) + if not _percentile_calls: + print(f"❌ [{fixture_name}] damage_stats: np.percentile was never called — " + f"values must be computed with NumPy, not plain Python arithmetic " + f"on a ceremonially-created array") + raise SystemExit(1) + print(f"✓ [{fixture_name}] damage_stats → {result} (np.percentile confirmed used)") + + # ── Task 4: rank_monsters_by_difficulty (behavior) ───────────────── + result = rank_monsters_by_difficulty(df) + expected = _ground_truth_ranking(raw_rows) + if list(result) != expected: + print(f"❌ [{fixture_name}] rank_monsters_by_difficulty: expected {expected}, got {result}") + raise SystemExit(1) + print(f"✓ [{fixture_name}] rank_monsters_by_difficulty → {result}") + + # ── technique gate: win_rate_by_class must actually use groupby ────── + src = (mission / "task.py").read_text() + if not _function_uses_attr_call(src, "win_rate_by_class", "groupby"): + print("❌ win_rate_by_class must use pandas' groupby — " + "a per-class Python loop (e.g. `for cls in df['hero_class'].unique(): ...`) " + "produces the same numbers but skips the learning objective of this task") + raise SystemExit(1) + print("✓ win_rate_by_class uses pandas groupby") + + update_progress() + print("\n✅ Data Recall Checkpoint complete! Pandas and NumPy are back online.") + + +if __name__ == "__main__": + main() diff --git a/data_recall_checkpoint/data/combat_log_fixture.csv b/data_recall_checkpoint/data/combat_log_fixture.csv new file mode 100644 index 0000000..2c18869 --- /dev/null +++ b/data_recall_checkpoint/data/combat_log_fixture.csv @@ -0,0 +1,219 @@ +battle_id,turn,hero_name,hero_class,monster,action,damage_dealt,damage_taken,hero_hp,monster_hp,result +1,1,Dorn,rogue,Troll,attack,10,12,88,70,ongoing +1,2,Dorn,rogue,Troll,attack,9,16,72,61,ongoing +1,3,Dorn,rogue,Troll,attack,8,14,58,53,ongoing +1,4,Dorn,rogue,Troll,potion,0,12,58,53,ongoing +1,5,Dorn,rogue,Troll,potion,0,15,58,53,ongoing +1,6,Dorn,rogue,Troll,attack,8,12,46,45,ongoing +1,7,Dorn,rogue,Troll,attack,11,14,0,34,loss +2,1,Gwyn,warrior,Goblin,attack,13,6,114,17,ongoing +2,2,Gwyn,warrior,Goblin,attack,13,4,110,4,ongoing +2,3,Gwyn,warrior,Goblin,attack,11,3,107,0,ongoing +2,4,Gwyn,warrior,Goblin,attack,11,2,105,0,win +3,1,Gwyn,rogue,Dragon,attack,2,17,83,118,ongoing +3,2,Gwyn,rogue,Dragon,attack,4,20,63,114,ongoing +3,3,Gwyn,rogue,Dragon,attack,4,19,44,110,ongoing +3,4,Gwyn,rogue,Dragon,attack,2,18,26,108,ongoing +3,5,Gwyn,rogue,Dragon,attack,5,17,9,0,win +4,1,Beorn,warrior,Dragon,attack,5,18,102,115,ongoing +4,2,Beorn,warrior,Dragon,attack,3,18,84,112,ongoing +4,3,Beorn,warrior,Dragon,attack,1,19,65,111,ongoing +4,4,Beorn,warrior,Dragon,attack,1,14,51,110,ongoing +4,5,Beorn,warrior,Dragon,potion,0,18,48,110,ongoing +4,6,Beorn,warrior,Dragon,attack,5,17,31,105,ongoing +4,7,Beorn,warrior,Dragon,attack,1,19,0,104,loss +5,1,Hale,rogue,Orc,attack,12,10,90,38,ongoing +5,2,Hale,rogue,Orc,attack,9,7,83,29,ongoing +5,3,Hale,rogue,Orc,attack,10,9,74,19,ongoing +5,4,Hale,rogue,Orc,attack,8,11,63,11,ongoing +5,5,Hale,rogue,Orc,attack,10,10,53,1,ongoing +5,6,Hale,rogue,Orc,potion,0,9,64,1,ongoing +5,7,Hale,rogue,Orc,attack,8,9,55,0,ongoing +5,8,Hale,rogue,Orc,attack,9,9,0,0,loss +6,1,Hale,warrior,Dragon,attack,2,15,105,118,ongoing +6,2,Hale,warrior,Dragon,potion,0,15,100,118,ongoing +6,3,Hale,warrior,Dragon,potion,0,17,102,118,ongoing +6,4,Hale,warrior,Dragon,attack,4,14,88,114,ongoing +6,5,Hale,warrior,Dragon,attack,1,16,72,113,ongoing +6,6,Hale,warrior,Dragon,attack,1,18,54,112,ongoing +6,7,Hale,warrior,Dragon,attack,2,16,38,110,ongoing +6,8,Hale,warrior,Dragon,attack,5,15,23,0,win +7,1,Eska,mage,Troll,attack,15,13,67,65,ongoing +7,2,Eska,mage,Troll,attack,11,13,54,54,ongoing +7,3,Eska,mage,Troll,attack,11,14,40,43,ongoing +7,4,Eska,mage,Troll,potion,0,12,45,43,ongoing +7,5,Eska,mage,Troll,attack,15,13,0,28,loss +8,1,Finn,rogue,Orc,attack,10,12,88,40,ongoing +8,2,Finn,rogue,Orc,attack,10,8,80,30,ongoing +8,3,Finn,rogue,Orc,attack,9,10,70,21,ongoing +8,4,Finn,rogue,Orc,attack,8,8,62,13,ongoing +8,5,Finn,rogue,Orc,potion,0,9,70,13,ongoing +8,6,Finn,rogue,Orc,attack,11,12,58,0,win +9,1,Ada,mage,Orc,attack,16,8,72,34,ongoing +9,2,Ada,mage,Orc,potion,0,11,80,34,ongoing +9,3,Ada,mage,Orc,attack,16,9,71,18,ongoing +9,4,Ada,mage,Orc,attack,17,8,63,1,ongoing +9,5,Ada,mage,Orc,potion,0,10,72,1,ongoing +9,6,Ada,mage,Orc,attack,17,8,64,0,ongoing +9,7,Ada,mage,Orc,attack,15,10,54,0,ongoing +9,8,Ada,mage,Orc,attack,16,13,0,0,loss +10,1,Beorn,warrior,Dragon,potion,0,14,119,120,ongoing +10,2,Beorn,warrior,Dragon,attack,5,18,101,115,ongoing +10,3,Beorn,warrior,Dragon,potion,0,15,99,115,ongoing +10,4,Beorn,warrior,Dragon,attack,1,19,80,114,ongoing +10,5,Beorn,warrior,Dragon,attack,4,19,0,110,loss +11,1,Dorn,mage,Orc,attack,17,13,67,33,ongoing +11,2,Dorn,mage,Orc,attack,13,8,59,20,ongoing +11,3,Dorn,mage,Orc,attack,16,12,47,0,win +12,1,Ada,mage,Orc,attack,12,8,72,38,ongoing +12,2,Ada,mage,Orc,attack,15,12,60,23,ongoing +12,3,Ada,mage,Orc,attack,16,12,48,7,ongoing +12,4,Ada,mage,Orc,potion,0,10,54,7,ongoing +12,5,Ada,mage,Orc,attack,13,13,41,0,win +13,1,Ada,warrior,Troll,attack,6,12,108,74,ongoing +13,2,Ada,warrior,Troll,attack,5,11,0,69,loss +14,1,Gwyn,rogue,Dragon,attack,7,20,80,113,ongoing +14,2,Gwyn,rogue,Dragon,attack,4,20,60,109,ongoing +14,3,Gwyn,rogue,Dragon,potion,0,17,59,109,ongoing +14,4,Gwyn,rogue,Dragon,attack,5,16,43,104,ongoing +14,5,Gwyn,rogue,Dragon,attack,3,19,24,101,ongoing +14,6,Gwyn,rogue,Dragon,attack,5,18,6,96,ongoing +14,7,Gwyn,rogue,Dragon,attack,4,15,0,92,ongoing +14,8,Gwyn,rogue,Dragon,attack,5,17,0,0,win +15,1,Ada,warrior,Troll,attack,7,11,109,73,ongoing +15,2,Ada,warrior,Troll,attack,6,14,0,67,loss +16,1,Finn,mage,Troll,attack,12,12,68,68,ongoing +16,2,Finn,mage,Troll,attack,11,17,0,57,loss +17,1,Ada,mage,Dragon,attack,9,16,64,111,ongoing +17,2,Ada,mage,Dragon,attack,8,16,0,103,loss +18,1,Dorn,warrior,Dragon,attack,5,15,105,115,ongoing +18,2,Dorn,warrior,Dragon,attack,5,19,86,110,ongoing +18,3,Dorn,warrior,Dragon,attack,2,16,70,108,ongoing +18,4,Dorn,warrior,Dragon,attack,5,15,55,103,ongoing +18,5,Dorn,warrior,Dragon,attack,5,18,37,0,win +19,1,Eska,mage,Orc,attack,14,9,71,36,ongoing +19,2,Eska,mage,Orc,attack,16,8,63,20,ongoing +19,3,Eska,mage,Orc,potion,0,8,65,20,ongoing +19,4,Eska,mage,Orc,attack,15,10,55,5,ongoing +19,5,Eska,mage,Orc,attack,14,12,43,0,ongoing +19,6,Eska,mage,Orc,attack,14,9,0,0,loss +20,1,Finn,mage,Orc,attack,12,11,69,38,ongoing +20,2,Finn,mage,Orc,potion,0,11,69,38,ongoing +20,3,Finn,mage,Orc,attack,12,13,56,26,ongoing +20,4,Finn,mage,Orc,attack,14,11,45,12,ongoing +20,5,Finn,mage,Orc,attack,12,12,33,0,ongoing +20,6,Finn,mage,Orc,attack,17,12,21,0,ongoing +20,7,Finn,mage,Orc,attack,15,13,8,0,ongoing +20,8,Finn,mage,Orc,attack,15,9,0,0,loss +21,1,Ada,mage,Goblin,attack,15,8,72,15,ongoing +21,2,Ada,mage,Goblin,potion,0,6,80,15,ongoing +21,3,Ada,mage,Goblin,attack,19,5,75,0,ongoing +21,4,Ada,mage,Goblin,attack,18,8,67,0,win +22,1,Beorn,warrior,Orc,attack,6,10,110,44,ongoing +22,2,Beorn,warrior,Orc,potion,0,9,115,44,ongoing +22,3,Beorn,warrior,Orc,attack,7,7,108,0,win +23,1,Celia,warrior,Dragon,potion,0,16,115,120,ongoing +23,2,Celia,warrior,Dragon,attack,1,19,96,119,ongoing +23,3,Celia,warrior,Dragon,potion,0,16,90,119,ongoing +23,4,Celia,warrior,Dragon,attack,1,19,71,118,ongoing +23,5,Celia,warrior,Dragon,attack,5,17,54,113,ongoing +23,6,Celia,warrior,Dragon,attack,1,16,38,112,ongoing +23,7,Celia,warrior,Dragon,potion,0,18,35,112,ongoing +23,8,Celia,warrior,Dragon,attack,2,19,0,110,loss +24,1,Gwyn,mage,Orc,attack,16,11,69,34,ongoing +24,2,Gwyn,mage,Orc,attack,13,13,56,21,ongoing +24,3,Gwyn,mage,Orc,attack,17,8,48,4,ongoing +24,4,Gwyn,mage,Orc,attack,15,8,40,0,win +25,1,Gwyn,warrior,Troll,attack,5,11,109,75,ongoing +25,2,Gwyn,warrior,Troll,potion,0,13,116,75,ongoing +25,3,Gwyn,warrior,Troll,attack,7,10,106,68,ongoing +25,4,Gwyn,warrior,Troll,attack,8,11,95,60,ongoing +25,5,Gwyn,warrior,Troll,attack,8,14,81,0,win +26,1,Gwyn,warrior,Troll,attack,8,15,105,72,ongoing +26,2,Gwyn,warrior,Troll,attack,4,11,94,68,ongoing +26,3,Gwyn,warrior,Troll,attack,7,12,82,61,ongoing +26,4,Gwyn,warrior,Troll,attack,8,11,71,53,ongoing +26,5,Gwyn,warrior,Troll,attack,9,15,56,44,ongoing +26,6,Gwyn,warrior,Troll,attack,9,11,45,35,ongoing +26,7,Gwyn,warrior,Troll,attack,5,12,0,30,loss +27,1,Celia,warrior,Dragon,attack,2,17,103,118,ongoing +27,2,Celia,warrior,Dragon,attack,3,19,84,115,ongoing +27,3,Celia,warrior,Dragon,attack,5,14,70,110,ongoing +27,4,Celia,warrior,Dragon,attack,2,17,53,108,ongoing +27,5,Celia,warrior,Dragon,potion,0,14,54,108,ongoing +27,6,Celia,warrior,Dragon,attack,2,14,40,106,ongoing +27,7,Celia,warrior,Dragon,attack,1,14,0,105,loss +28,1,Hale,mage,Troll,attack,14,13,67,66,ongoing +28,2,Hale,mage,Troll,attack,11,16,51,55,ongoing +28,3,Hale,mage,Troll,attack,12,16,35,43,ongoing +28,4,Hale,mage,Troll,potion,0,13,37,43,ongoing +28,5,Hale,mage,Troll,attack,10,13,24,33,ongoing +28,6,Hale,mage,Troll,attack,15,14,10,18,ongoing +28,7,Hale,mage,Troll,attack,12,16,0,6,ongoing +28,8,Hale,mage,Troll,attack,12,17,0,0,win +29,1,Celia,mage,Orc,potion,0,10,80,50,ongoing +29,2,Celia,mage,Orc,attack,13,10,70,0,win +30,1,Celia,warrior,Troll,attack,6,10,110,74,ongoing +30,2,Celia,warrior,Troll,attack,5,15,95,69,ongoing +30,3,Celia,warrior,Troll,potion,0,10,99,69,ongoing +30,4,Celia,warrior,Troll,attack,7,10,89,62,ongoing +30,5,Celia,warrior,Troll,attack,4,13,76,58,ongoing +30,6,Celia,warrior,Troll,attack,7,13,63,0,win +31,1,Finn,rogue,Dragon,attack,6,18,82,114,ongoing +31,2,Finn,rogue,Dragon,attack,7,15,67,0,win +32,1,Gwyn,warrior,Orc,attack,6,10,110,44,ongoing +32,2,Gwyn,warrior,Orc,attack,10,10,100,34,ongoing +32,3,Gwyn,warrior,Orc,attack,11,9,91,23,ongoing +32,4,Gwyn,warrior,Orc,attack,9,6,85,14,ongoing +32,5,Gwyn,warrior,Orc,attack,7,8,77,0,win +33,1,Finn,rogue,Orc,attack,12,12,88,38,ongoing +33,2,Finn,rogue,Orc,attack,10,7,81,28,ongoing +33,3,Finn,rogue,Orc,attack,13,12,69,0,win +34,1,Celia,mage,Goblin,attack,19,8,72,11,ongoing +34,2,Celia,mage,Goblin,attack,14,8,64,0,ongoing +34,3,Celia,mage,Goblin,attack,15,7,57,0,ongoing +34,4,Celia,mage,Goblin,potion,0,7,62,0,ongoing +34,5,Celia,mage,Goblin,attack,17,8,54,0,ongoing +34,6,Celia,mage,Goblin,attack,18,4,50,0,ongoing +34,7,Celia,mage,Goblin,attack,14,8,42,0,win +35,1,Ada,mage,Orc,attack,17,9,71,33,ongoing +35,2,Ada,mage,Orc,attack,14,12,59,19,ongoing +35,3,Ada,mage,Orc,attack,15,13,46,4,ongoing +35,4,Ada,mage,Orc,potion,0,8,58,4,ongoing +35,5,Ada,mage,Orc,attack,15,9,49,0,win +36,1,Dorn,rogue,Orc,attack,12,12,88,38,ongoing +36,2,Dorn,rogue,Orc,potion,0,9,92,38,ongoing +36,3,Dorn,rogue,Orc,attack,11,7,85,27,ongoing +36,4,Dorn,rogue,Orc,potion,0,8,96,27,ongoing +36,5,Dorn,rogue,Orc,attack,9,10,86,18,ongoing +36,6,Dorn,rogue,Orc,attack,11,7,79,7,ongoing +36,7,Dorn,rogue,Orc,attack,13,9,70,0,ongoing +36,8,Dorn,rogue,Orc,attack,8,8,62,0,win +37,1,Finn,mage,Orc,attack,13,10,70,37,ongoing +37,2,Finn,mage,Orc,attack,12,8,62,25,ongoing +37,3,Finn,mage,Orc,potion,0,8,65,25,ongoing +37,4,Finn,mage,Orc,attack,17,13,52,8,ongoing +37,5,Finn,mage,Orc,attack,15,10,42,0,ongoing +37,6,Finn,mage,Orc,attack,17,8,34,0,ongoing +37,7,Finn,mage,Orc,attack,13,13,21,0,ongoing +37,8,Finn,mage,Orc,attack,16,8,13,0,win +38,1,Hale,mage,Troll,attack,12,13,67,68,ongoing +38,2,Hale,mage,Troll,attack,15,14,0,53,loss +39,1,Hale,rogue,Orc,attack,11,10,90,39,ongoing +39,2,Hale,rogue,Orc,attack,10,12,78,29,ongoing +39,3,Hale,rogue,Orc,attack,8,11,67,0,win +40,1,Dorn,warrior,Goblin,attack,12,4,116,18,ongoing +40,2,Dorn,warrior,Goblin,potion,0,5,120,18,ongoing +40,3,Dorn,warrior,Goblin,attack,10,5,115,8,ongoing +40,4,Dorn,warrior,Goblin,attack,10,3,112,0,ongoing +40,5,Dorn,warrior,Goblin,attack,12,7,105,0,win +41,1,Finn,warrior,Orc,attack,10,10,110,40,ongoing +41,2,Finn,warrior,Orc,attack,11,10,100,0,win +42,1,Hale,warrior,Troll,attack,4,15,105,76,ongoing +42,2,Hale,warrior,Troll,attack,5,13,92,71,ongoing +42,3,Hale,warrior,Troll,attack,4,12,80,67,ongoing +42,4,Hale,warrior,Troll,attack,5,13,67,62,ongoing +42,5,Hale,warrior,Troll,potion,0,12,67,62,ongoing +42,6,Hale,warrior,Troll,attack,8,13,54,54,ongoing +42,7,Hale,warrior,Troll,attack,9,12,42,45,ongoing +42,8,Hale,warrior,Troll,attack,8,10,32,0,win diff --git a/data_recall_checkpoint/data/combat_log_fixture_variant.csv b/data_recall_checkpoint/data/combat_log_fixture_variant.csv new file mode 100644 index 0000000..8fb0559 --- /dev/null +++ b/data_recall_checkpoint/data/combat_log_fixture_variant.csv @@ -0,0 +1,210 @@ +battle_id,turn,hero_name,hero_class,monster,action,damage_dealt,damage_taken,hero_hp,monster_hp,result +1,1,Gwyn,rogue,Dragon,attack,5,15,85,115,ongoing +1,2,Gwyn,rogue,Dragon,attack,5,15,70,110,ongoing +1,3,Gwyn,rogue,Dragon,attack,6,17,53,0,win +2,1,Dorn,warrior,Dragon,attack,4,15,105,116,ongoing +2,2,Dorn,warrior,Dragon,attack,2,17,88,114,ongoing +2,3,Dorn,warrior,Dragon,potion,0,17,91,114,ongoing +2,4,Dorn,warrior,Dragon,potion,0,15,90,114,ongoing +2,5,Dorn,warrior,Dragon,attack,3,16,74,111,ongoing +2,6,Dorn,warrior,Dragon,attack,1,19,55,110,ongoing +2,7,Dorn,warrior,Dragon,attack,1,17,38,109,ongoing +2,8,Dorn,warrior,Dragon,attack,5,14,24,0,win +3,1,Celia,warrior,Orc,attack,11,11,109,39,ongoing +3,2,Celia,warrior,Orc,attack,7,8,101,32,ongoing +3,3,Celia,warrior,Orc,attack,11,8,93,0,win +4,1,Dorn,mage,Troll,attack,11,14,66,69,ongoing +4,2,Dorn,mage,Troll,attack,14,13,53,55,ongoing +4,3,Dorn,mage,Troll,attack,13,16,37,42,ongoing +4,4,Dorn,mage,Troll,attack,12,14,23,30,ongoing +4,5,Dorn,mage,Troll,attack,13,13,10,0,win +5,1,Dorn,rogue,Troll,attack,10,13,87,70,ongoing +5,2,Dorn,rogue,Troll,attack,6,11,0,64,loss +6,1,Gwyn,rogue,Troll,attack,10,16,84,70,ongoing +6,2,Gwyn,rogue,Troll,attack,11,13,71,59,ongoing +6,3,Gwyn,rogue,Troll,attack,11,12,59,48,ongoing +6,4,Gwyn,rogue,Troll,attack,11,15,44,37,ongoing +6,5,Gwyn,rogue,Troll,attack,9,11,33,28,ongoing +6,6,Gwyn,rogue,Troll,attack,7,13,20,21,ongoing +6,7,Gwyn,rogue,Troll,attack,11,16,4,0,win +7,1,Hale,mage,Orc,attack,13,13,67,37,ongoing +7,2,Hale,mage,Orc,attack,12,8,59,0,win +8,1,Beorn,mage,Dragon,attack,11,16,64,109,ongoing +8,2,Beorn,mage,Dragon,attack,9,16,48,100,ongoing +8,3,Beorn,mage,Dragon,attack,10,18,30,0,win +9,1,Gwyn,mage,Orc,attack,15,12,68,35,ongoing +9,2,Gwyn,mage,Orc,attack,13,11,57,22,ongoing +9,3,Gwyn,mage,Orc,attack,15,12,0,7,loss +10,1,Dorn,warrior,Orc,attack,6,11,109,44,ongoing +10,2,Dorn,warrior,Orc,attack,11,11,98,33,ongoing +10,3,Dorn,warrior,Orc,attack,11,9,89,22,ongoing +10,4,Dorn,warrior,Orc,attack,9,10,79,13,ongoing +10,5,Dorn,warrior,Orc,attack,9,8,71,0,win +11,1,Eska,warrior,Orc,attack,11,10,110,39,ongoing +11,2,Eska,warrior,Orc,attack,9,10,100,30,ongoing +11,3,Eska,warrior,Orc,attack,8,7,93,22,ongoing +11,4,Eska,warrior,Orc,attack,8,11,82,14,ongoing +11,5,Eska,warrior,Orc,attack,6,10,72,8,ongoing +11,6,Eska,warrior,Orc,attack,11,8,64,0,win +12,1,Dorn,mage,Dragon,potion,0,20,78,120,ongoing +12,2,Dorn,mage,Dragon,attack,9,18,60,111,ongoing +12,3,Dorn,mage,Dragon,attack,8,17,43,0,win +13,1,Dorn,warrior,Goblin,attack,9,3,117,21,ongoing +13,2,Dorn,warrior,Goblin,attack,9,5,112,12,ongoing +13,3,Dorn,warrior,Goblin,potion,0,2,120,12,ongoing +13,4,Dorn,warrior,Goblin,attack,10,4,116,2,ongoing +13,5,Dorn,warrior,Goblin,attack,9,7,109,0,ongoing +13,6,Dorn,warrior,Goblin,attack,11,2,107,0,ongoing +13,7,Dorn,warrior,Goblin,attack,8,5,0,0,loss +14,1,Eska,rogue,Dragon,potion,0,15,100,120,ongoing +14,2,Eska,rogue,Dragon,attack,4,20,80,116,ongoing +14,3,Eska,rogue,Dragon,attack,4,17,63,112,ongoing +14,4,Eska,rogue,Dragon,attack,4,16,47,108,ongoing +14,5,Eska,rogue,Dragon,attack,4,20,27,104,ongoing +14,6,Eska,rogue,Dragon,attack,7,16,11,0,win +15,1,Gwyn,rogue,Troll,attack,8,12,88,72,ongoing +15,2,Gwyn,rogue,Troll,attack,11,15,73,61,ongoing +15,3,Gwyn,rogue,Troll,attack,11,12,61,50,ongoing +15,4,Gwyn,rogue,Troll,attack,10,11,50,40,ongoing +15,5,Gwyn,rogue,Troll,attack,11,12,38,29,ongoing +15,6,Gwyn,rogue,Troll,attack,10,16,22,19,ongoing +15,7,Gwyn,rogue,Troll,attack,11,15,7,0,win +16,1,Dorn,mage,Troll,attack,13,16,64,67,ongoing +16,2,Dorn,mage,Troll,attack,13,14,50,54,ongoing +16,3,Dorn,mage,Troll,attack,13,16,34,41,ongoing +16,4,Dorn,mage,Troll,attack,14,13,21,27,ongoing +16,5,Dorn,mage,Troll,potion,0,12,24,27,ongoing +16,6,Dorn,mage,Troll,attack,11,17,7,0,win +17,1,Dorn,rogue,Orc,attack,12,9,91,38,ongoing +17,2,Dorn,rogue,Orc,attack,8,7,84,30,ongoing +17,3,Dorn,rogue,Orc,attack,8,10,74,22,ongoing +17,4,Dorn,rogue,Orc,potion,0,7,82,22,ongoing +17,5,Dorn,rogue,Orc,attack,12,9,73,10,ongoing +17,6,Dorn,rogue,Orc,attack,10,7,66,0,ongoing +17,7,Dorn,rogue,Orc,attack,13,9,0,0,loss +18,1,Dorn,warrior,Orc,attack,7,10,110,43,ongoing +18,2,Dorn,warrior,Orc,attack,7,7,103,36,ongoing +18,3,Dorn,warrior,Orc,attack,11,9,94,25,ongoing +18,4,Dorn,warrior,Orc,potion,0,7,97,25,ongoing +18,5,Dorn,warrior,Orc,attack,6,7,90,0,win +19,1,Dorn,mage,Dragon,attack,8,18,62,112,ongoing +19,2,Dorn,mage,Dragon,attack,8,17,45,104,ongoing +19,3,Dorn,mage,Dragon,potion,0,18,46,104,ongoing +19,4,Dorn,mage,Dragon,attack,9,16,30,95,ongoing +19,5,Dorn,mage,Dragon,attack,10,20,10,85,ongoing +19,6,Dorn,mage,Dragon,attack,10,19,0,0,win +20,1,Hale,mage,Orc,attack,14,9,71,36,ongoing +20,2,Hale,mage,Orc,attack,14,8,63,22,ongoing +20,3,Hale,mage,Orc,attack,16,8,0,6,loss +21,1,Eska,mage,Goblin,attack,15,8,72,15,ongoing +21,2,Eska,mage,Goblin,attack,14,4,68,1,ongoing +21,3,Eska,mage,Goblin,attack,16,8,60,0,ongoing +21,4,Eska,mage,Goblin,attack,14,5,0,0,loss +22,1,Beorn,rogue,Troll,attack,9,15,85,71,ongoing +22,2,Beorn,rogue,Troll,attack,10,11,74,61,ongoing +22,3,Beorn,rogue,Troll,attack,11,13,61,50,ongoing +22,4,Beorn,rogue,Troll,potion,0,15,62,50,ongoing +22,5,Beorn,rogue,Troll,attack,7,11,51,43,ongoing +22,6,Beorn,rogue,Troll,attack,7,11,40,36,ongoing +22,7,Beorn,rogue,Troll,attack,10,13,0,26,loss +23,1,Gwyn,warrior,Goblin,attack,9,6,114,21,ongoing +23,2,Gwyn,warrior,Goblin,attack,10,3,111,11,ongoing +23,3,Gwyn,warrior,Goblin,attack,8,3,108,3,ongoing +23,4,Gwyn,warrior,Goblin,attack,12,2,106,0,ongoing +23,5,Gwyn,warrior,Goblin,attack,10,7,99,0,ongoing +23,6,Gwyn,warrior,Goblin,attack,12,6,93,0,ongoing +23,7,Gwyn,warrior,Goblin,attack,8,4,89,0,ongoing +23,8,Gwyn,warrior,Goblin,attack,13,5,0,0,loss +24,1,Beorn,mage,Dragon,attack,8,21,59,112,ongoing +24,2,Beorn,mage,Dragon,attack,7,21,38,0,win +25,1,Ada,mage,Goblin,attack,18,5,75,12,ongoing +25,2,Ada,mage,Goblin,attack,16,6,69,0,ongoing +25,3,Ada,mage,Goblin,attack,17,8,61,0,ongoing +25,4,Ada,mage,Goblin,attack,18,7,54,0,ongoing +25,5,Ada,mage,Goblin,potion,0,4,62,0,ongoing +25,6,Ada,mage,Goblin,attack,16,5,57,0,win +26,1,Finn,mage,Dragon,attack,7,21,59,113,ongoing +26,2,Finn,mage,Dragon,attack,11,19,40,102,ongoing +26,3,Finn,mage,Dragon,attack,8,21,19,94,ongoing +26,4,Finn,mage,Dragon,attack,9,21,0,85,ongoing +26,5,Finn,mage,Dragon,attack,11,20,0,74,ongoing +26,6,Finn,mage,Dragon,attack,6,18,0,0,win +27,1,Hale,warrior,Goblin,attack,13,3,117,17,ongoing +27,2,Hale,warrior,Goblin,attack,11,6,111,6,ongoing +27,3,Hale,warrior,Goblin,attack,13,5,106,0,ongoing +27,4,Hale,warrior,Goblin,potion,0,3,120,0,ongoing +27,5,Hale,warrior,Goblin,attack,12,6,114,0,ongoing +27,6,Hale,warrior,Goblin,attack,12,6,108,0,ongoing +27,7,Hale,warrior,Goblin,attack,9,6,102,0,ongoing +27,8,Hale,warrior,Goblin,attack,11,5,97,0,win +28,1,Gwyn,warrior,Goblin,attack,11,2,118,19,ongoing +28,2,Gwyn,warrior,Goblin,attack,13,5,113,6,ongoing +28,3,Gwyn,warrior,Goblin,attack,10,2,111,0,ongoing +28,4,Gwyn,warrior,Goblin,attack,11,7,104,0,ongoing +28,5,Gwyn,warrior,Goblin,attack,13,5,0,0,loss +29,1,Beorn,rogue,Dragon,potion,0,19,100,120,ongoing +29,2,Beorn,rogue,Dragon,attack,3,18,82,117,ongoing +29,3,Beorn,rogue,Dragon,attack,7,20,62,0,win +30,1,Beorn,rogue,Dragon,attack,3,18,82,117,ongoing +30,2,Beorn,rogue,Dragon,attack,6,17,65,111,ongoing +30,3,Beorn,rogue,Dragon,attack,7,16,49,104,ongoing +30,4,Beorn,rogue,Dragon,attack,3,17,32,101,ongoing +30,5,Beorn,rogue,Dragon,attack,4,16,16,97,ongoing +30,6,Beorn,rogue,Dragon,attack,5,16,0,92,ongoing +30,7,Beorn,rogue,Dragon,attack,6,16,0,86,ongoing +30,8,Beorn,rogue,Dragon,attack,6,18,0,0,win +31,1,Beorn,mage,Troll,attack,10,12,68,70,ongoing +31,2,Beorn,mage,Troll,attack,15,14,54,55,ongoing +31,3,Beorn,mage,Troll,attack,12,13,0,43,loss +32,1,Dorn,warrior,Troll,attack,6,10,110,74,ongoing +32,2,Dorn,warrior,Troll,attack,8,11,99,66,ongoing +32,3,Dorn,warrior,Troll,attack,9,10,0,57,loss +33,1,Dorn,rogue,Dragon,potion,0,18,100,120,ongoing +33,2,Dorn,rogue,Dragon,attack,6,15,85,114,ongoing +33,3,Dorn,rogue,Dragon,potion,0,18,85,114,ongoing +33,4,Dorn,rogue,Dragon,attack,6,16,69,108,ongoing +33,5,Dorn,rogue,Dragon,attack,6,18,51,0,win +34,1,Eska,mage,Goblin,attack,18,9,71,12,ongoing +34,2,Eska,mage,Goblin,attack,16,9,62,0,ongoing +34,3,Eska,mage,Goblin,attack,17,6,56,0,ongoing +34,4,Eska,mage,Goblin,attack,15,9,0,0,loss +35,1,Ada,warrior,Goblin,potion,0,2,120,30,ongoing +35,2,Ada,warrior,Goblin,attack,9,7,113,21,ongoing +35,3,Ada,warrior,Goblin,attack,9,5,108,12,ongoing +35,4,Ada,warrior,Goblin,potion,0,2,119,12,ongoing +35,5,Ada,warrior,Goblin,attack,9,7,112,3,ongoing +35,6,Ada,warrior,Goblin,attack,11,7,105,0,ongoing +35,7,Ada,warrior,Goblin,attack,9,6,99,0,win +36,1,Hale,warrior,Goblin,attack,11,6,114,19,ongoing +36,2,Hale,warrior,Goblin,attack,9,5,0,10,loss +37,1,Eska,warrior,Goblin,attack,8,6,114,22,ongoing +37,2,Eska,warrior,Goblin,attack,11,5,109,11,ongoing +37,3,Eska,warrior,Goblin,potion,0,3,120,11,ongoing +37,4,Eska,warrior,Goblin,attack,12,6,114,0,win +38,1,Eska,warrior,Dragon,potion,0,14,120,120,ongoing +38,2,Eska,warrior,Dragon,attack,5,17,103,0,win +39,1,Beorn,mage,Goblin,attack,17,9,71,13,ongoing +39,2,Beorn,mage,Goblin,attack,14,4,67,0,ongoing +39,3,Beorn,mage,Goblin,attack,15,9,58,0,ongoing +39,4,Beorn,mage,Goblin,potion,0,5,64,0,ongoing +39,5,Beorn,mage,Goblin,attack,17,6,0,0,loss +40,1,Eska,mage,Dragon,attack,9,19,61,111,ongoing +40,2,Eska,mage,Dragon,attack,9,20,41,102,ongoing +40,3,Eska,mage,Dragon,attack,11,18,23,91,ongoing +40,4,Eska,mage,Dragon,attack,9,19,4,82,ongoing +40,5,Eska,mage,Dragon,attack,10,17,0,72,ongoing +40,6,Eska,mage,Dragon,attack,10,18,0,62,ongoing +40,7,Eska,mage,Dragon,attack,7,20,0,0,win +41,1,Finn,warrior,Goblin,attack,12,6,114,18,ongoing +41,2,Finn,warrior,Goblin,potion,0,6,120,18,ongoing +41,3,Finn,warrior,Goblin,attack,11,4,116,7,ongoing +41,4,Finn,warrior,Goblin,attack,8,4,112,0,ongoing +41,5,Finn,warrior,Goblin,attack,12,7,105,0,ongoing +41,6,Finn,warrior,Goblin,attack,12,6,99,0,ongoing +41,7,Finn,warrior,Goblin,potion,0,2,115,0,ongoing +41,8,Finn,warrior,Goblin,attack,9,7,0,0,loss +42,1,Celia,warrior,Troll,attack,7,14,106,73,ongoing +42,2,Celia,warrior,Troll,attack,9,10,96,64,ongoing +42,3,Celia,warrior,Troll,attack,6,15,81,58,ongoing +42,4,Celia,warrior,Troll,attack,4,12,69,54,ongoing +42,5,Celia,warrior,Troll,attack,6,14,55,0,win diff --git a/data_recall_checkpoint/task.py b/data_recall_checkpoint/task.py new file mode 100644 index 0000000..786901a --- /dev/null +++ b/data_recall_checkpoint/task.py @@ -0,0 +1,113 @@ +"""Data Recall Checkpoint: Combat Log Analytics. + +You learned NumPy and Pandas back in Level 1. Since then the RPG gained +validation (Pydantic) and persistence (the repository pattern) in Level 3 — +including a real, validated combat log. Before moving into Level 4's +interfaces and tooling, use those data skills again on data your application +now knows how to store. + +See README.md for the full brief, a short cheat-sheet, and the analytical +questions this checkpoint asks you to answer. + +Check: uv run python check.py +""" +from pathlib import Path + +import numpy as np +import pandas as pd + +DATA_DIR = Path(__file__).parent / "data" + + +def load_combat_log(path: Path) -> pd.DataFrame: + """Load a combat log CSV into a DataFrame. + + The CSV has one row per turn (see README for the exact column list). + Return the full DataFrame, unfiltered — separating finished battles from + in-progress ("ongoing") turns is the job of the functions below, not this + one. + + TODO: + - read the CSV at `path` with pandas + - return the resulting DataFrame + """ + raise NotImplementedError + + +def win_rate_by_class(df: pd.DataFrame) -> dict[str, float]: + """Hero win rate per hero_class, using only finished battles. + + A battle is one `battle_id`; most battles span several turn rows, and + only the LAST row of a battle has a terminal `result` ("win" or "loss") — + every earlier row is "ongoing". Win rate must be computed over terminal + rows only, grouped by `hero_class`. + + Returns a dict like {"warrior": 0.59, "mage": 0.56, "rogue": 0.78} — one + entry per hero_class that has at least one finished battle. + + TODO: + - keep only rows where result != "ongoing" + - group the result by hero_class using pandas' groupby + - for each group, compute the fraction of rows where result == "win" + - return the per-class win rates as a plain dict + """ + raise NotImplementedError + + +def damage_stats(df: pd.DataFrame) -> dict[str, float | int]: + """NumPy statistics over attack damage. + + `action` is either "attack" or "potion". A potion turn deals no damage + by definition (damage_dealt is 0 because the hero healed instead of + attacking, not because the attack was weak) — including those zeros + would understate how hard hero attacks actually hit. Only "attack" rows + belong in this analysis. + + Using the `damage_dealt` values from attack rows as a NumPy array, + return a dict with exactly these keys: + - "mean": the mean damage + - "std": the population standard deviation (i.e. plain `ndarray.std()` — + no sample correction) + - "p75": the 75th percentile (`np.percentile(arr, 75)`, linear + interpolation — the default) + - "high_damage_count": how many attack rows dealt more damage than p75 + + TODO: + - filter to action == "attack" + - convert the damage_dealt column to a NumPy array + - compute mean, std, and the 75th percentile with NumPy + - build a boolean mask for damage > p75 and count it + - return the four values in a dict + """ + raise NotImplementedError + + +def rank_monsters_by_difficulty(df: pd.DataFrame) -> list[str]: + """Rank monsters from hardest to easiest, by hero win rate. + + "Difficulty" here is only this one operational metric: the monster the + hero beats LEAST often (lowest hero win rate, over finished battles) is + ranked hardest. This is not a universal definition of difficulty — it + ignores average battle length, damage exchanged, hero class matchups, + and everything else. It is simply what this exercise asks you to + compute. + + Return monster names ordered from hardest (lowest win rate) to easiest + (highest win rate). + + TODO: + - keep only finished battles + - group by monster and compute hero win rate per monster (same idea as + win_rate_by_class, just grouped on a different column) + - sort the monsters by win rate, ascending + - return the monster names as a list, in that order + """ + raise NotImplementedError + + +if __name__ == "__main__": + df = load_combat_log(DATA_DIR / "combat_log_fixture.csv") + print(f"Loaded {len(df)} rows.") + print("Win rate by class:", win_rate_by_class(df)) + print("Damage stats:", damage_stats(df)) + print("Monsters, hardest to easiest:", rank_monsters_by_difficulty(df)) diff --git a/tools/author_check.py b/tools/author_check.py index 924f94a..7d118f7 100644 --- a/tools/author_check.py +++ b/tools/author_check.py @@ -16,6 +16,7 @@ LEVEL1_ROOT = REPO_ROOT / "level_1_python_basics" LEVEL2_ROOT = REPO_ROOT / "level_2_oop_and_design" LEVEL3_ROOT = REPO_ROOT / "level_3_validation_and_persistence" +DATA_CHECKPOINT_ROOT = REPO_ROOT / "data_recall_checkpoint" LEVEL4_ROOT = REPO_ROOT / "level_4_interfaces" LEVEL5_ROOT = REPO_ROOT / "level_5_maintainability" LEVEL6_ROOT = REPO_ROOT / "level_6_api" @@ -210,6 +211,44 @@ def check_folder_structure( f"MISSING: {label}/projects/{project_id}/{fname}") +def check_data_checkpoint(root: Path) -> None: + """Verify the (flat, non-mission/project-shaped) Data Recall Checkpoint. + + It has its own README/task.py/check.py/data files directly under its + root — not nested under missions/ or projects/ like a level — so it + doesn't fit check_folder_structure()'s convention. + """ + for fname in ("README.md", "task.py", "check.py"): + fpath = root / fname + check( + fpath.exists(), + f"data_recall_checkpoint/{fname}", + f"MISSING: data_recall_checkpoint/{fname}", + ) + for fname in ("combat_log_fixture.csv", "combat_log_fixture_variant.csv"): + fpath = root / "data" / fname + check( + fpath.exists(), + f"data_recall_checkpoint/data/{fname}", + f"MISSING: data_recall_checkpoint/data/{fname}", + ) + + check_py = root / "check.py" + if check_py.exists(): + text = check_py.read_text() + rel = check_py.relative_to(REPO_ROOT) + check( + "raise SystemExit(1)" in text, + f"{rel}: has SystemExit(1)", + f"{rel}: MISSING raise SystemExit(1) in except block", + ) + check( + '"uv", "run", "python"' not in text, + f"{rel}: no uv-run-python subprocess", + f"{rel}: uses ['uv','run','python',...] — change to sys.executable", + ) + + def check_hygiene(level_root: Path, missions: list[str], projects: list[str]) -> None: all_checks = ( [level_root / "missions" / m / "check.py" for m in missions] @@ -325,6 +364,9 @@ def check_dependencies() -> None: print("Checking Level 3 folder structure…") check_folder_structure(LEVEL3_ROOT, L3_MISSIONS, L3_PROJECTS, L3_MISSION_FILE_EXCEPTIONS, "level_3") +print("Checking Data Recall Checkpoint structure…") +check_data_checkpoint(DATA_CHECKPOINT_ROOT) + print("Checking Level 4 folder structure…") check_folder_structure(LEVEL4_ROOT, L4_MISSIONS, L4_PROJECTS, L4_MISSION_FILE_EXCEPTIONS, "level_4") @@ -394,6 +436,7 @@ def check_dependencies() -> None: (LEVEL1_ROOT, "level_1_python_basics", "Level 1"), (LEVEL2_ROOT, "level_2_oop_and_design", "Level 2"), (LEVEL3_ROOT, "level_3_validation_and_persistence", "Level 3"), + (DATA_CHECKPOINT_ROOT, "data_recall_checkpoint", "Data Recall Checkpoint"), (LEVEL4_ROOT, "level_4_interfaces", "Level 4"), (LEVEL5_ROOT, "level_5_maintainability", "Level 5"), (LEVEL6_ROOT, "level_6_api", "Level 6"), diff --git a/tools/course_status.py b/tools/course_status.py index 503ba5c..dc56a8a 100644 --- a/tools/course_status.py +++ b/tools/course_status.py @@ -5,11 +5,13 @@ LEVEL1_ROOT = REPO_ROOT / "level_1_python_basics" LEVEL2_ROOT = REPO_ROOT / "level_2_oop_and_design" LEVEL3_ROOT = REPO_ROOT / "level_3_validation_and_persistence" +DATA_CHECKPOINT_ROOT = REPO_ROOT / "data_recall_checkpoint" LEVEL4_ROOT = REPO_ROOT / "level_4_interfaces" LEVEL1_PROGRESS_FILE = LEVEL1_ROOT / ".progress" LEVEL2_PROGRESS_FILE = LEVEL2_ROOT / ".progress" LEVEL3_PROGRESS_FILE = LEVEL3_ROOT / ".progress" +DATA_CHECKPOINT_PROGRESS_FILE = DATA_CHECKPOINT_ROOT / ".progress" LEVEL4_PROGRESS_FILE = LEVEL4_ROOT / ".progress" LEVEL5_ROOT = REPO_ROOT / "level_5_maintainability" @@ -348,10 +350,38 @@ def print_level( next_up_ref[0] = f"{level_prefix}/projects/{project_id}/README.md" +def print_checkpoint( + name: str, + progress: dict, + checkpoint_id: str, + readme_path: str, + next_up_ref: list, +) -> None: + """Print a single, flat (non-mission/project) course checkpoint. + + Unlike a level, a checkpoint has no missions/projects subdirectory — + it's a single README/task.py/check.py unit, so it doesn't fit + print_level()'s path convention. Reuses the same "projects" progress + bucket so a checkpoint's completion is tracked the same way existing + mandatory units are. + """ + print() + print(name) + print("─" * 40) + print() + + status = progress.get("projects", {}).get(checkpoint_id, "not_started") + symbol = SYMBOLS[status] + print(f" [{symbol}] {name}") + if status != "complete" and not next_up_ref[0]: + next_up_ref[0] = readme_path + + def main() -> None: l1_progress = load_progress(LEVEL1_PROGRESS_FILE) l2_progress = load_progress(LEVEL2_PROGRESS_FILE) l3_progress = load_progress(LEVEL3_PROGRESS_FILE) + data_checkpoint_progress = load_progress(DATA_CHECKPOINT_PROGRESS_FILE) l4_progress = load_progress(LEVEL4_PROGRESS_FILE) l5_progress = load_progress(LEVEL5_PROGRESS_FILE) l6_progress = load_progress(LEVEL6_PROGRESS_FILE) @@ -369,6 +399,8 @@ def main() -> None: "level_2_oop_and_design", next_up) print_level("Level 3: Validation and Persistence", LEVEL3_WORLDS, l3_progress, "level_3_validation_and_persistence", next_up) + print_checkpoint("Data Recall Checkpoint", data_checkpoint_progress, + "data_recall_checkpoint", "data_recall_checkpoint/README.md", next_up) print_level("Level 4: Interfaces and Reports", LEVEL4_WORLDS, l4_progress, "level_4_interfaces", next_up) print_level("Level 5: Production Quality and Maintainability", LEVEL5_WORLDS, l5_progress,