python-manta 1.4.5.3__cp313-cp313-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2332 @@
1
+ """
2
+ Python interface for Manta Dota 2 replay parser using ctypes.
3
+ Provides basic file header reading functionality through Go CGO wrapper.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import bz2
8
+ import ctypes
9
+ import json
10
+ import os
11
+ import tempfile
12
+ from enum import Enum
13
+ from pathlib import Path
14
+ from typing import Optional, Dict, Any, List, Iterator
15
+ from pydantic import BaseModel, Field
16
+
17
+
18
+ class RuneType(str, Enum):
19
+ """Dota 2 rune types with their modifier names.
20
+
21
+ Usage:
22
+ # Check if a combat log entry is a rune pickup
23
+ if RuneType.from_modifier(entry.inflictor_name):
24
+ rune = RuneType.from_modifier(entry.inflictor_name)
25
+ print(f"Picked up {rune.display_name}")
26
+
27
+ # Get all rune modifiers for filtering
28
+ rune_modifiers = RuneType.all_modifiers()
29
+ """
30
+ DOUBLE_DAMAGE = "modifier_rune_doubledamage"
31
+ HASTE = "modifier_rune_haste"
32
+ ILLUSION = "modifier_rune_illusion"
33
+ INVISIBILITY = "modifier_rune_invis"
34
+ REGENERATION = "modifier_rune_regen"
35
+ ARCANE = "modifier_rune_arcane"
36
+ SHIELD = "modifier_rune_shield"
37
+ WATER = "modifier_rune_water"
38
+
39
+ @property
40
+ def display_name(self) -> str:
41
+ """Human-readable rune name."""
42
+ names = {
43
+ RuneType.DOUBLE_DAMAGE: "Double Damage",
44
+ RuneType.HASTE: "Haste",
45
+ RuneType.ILLUSION: "Illusion",
46
+ RuneType.INVISIBILITY: "Invisibility",
47
+ RuneType.REGENERATION: "Regeneration",
48
+ RuneType.ARCANE: "Arcane",
49
+ RuneType.SHIELD: "Shield",
50
+ RuneType.WATER: "Water",
51
+ }
52
+ return names[self]
53
+
54
+ @property
55
+ def modifier_name(self) -> str:
56
+ """Combat log modifier name for this rune."""
57
+ return self.value
58
+
59
+ @classmethod
60
+ def from_modifier(cls, modifier_name: str) -> Optional["RuneType"]:
61
+ """Get RuneType from a combat log modifier name.
62
+
63
+ Returns None if the modifier is not a rune modifier.
64
+ """
65
+ for rune in cls:
66
+ if rune.value == modifier_name:
67
+ return rune
68
+ return None
69
+
70
+ @classmethod
71
+ def all_modifiers(cls) -> List[str]:
72
+ """Get list of all rune modifier names for filtering."""
73
+ return [rune.value for rune in cls]
74
+
75
+ @classmethod
76
+ def is_rune_modifier(cls, modifier_name: str) -> bool:
77
+ """Check if a modifier name is a rune modifier."""
78
+ return modifier_name.startswith("modifier_rune_")
79
+
80
+
81
+ _SUMMON_PATTERNS = (
82
+ "lycan_wolf", "lone_druid_bear", "beastmaster_boar", "beastmaster_hawk",
83
+ "enigma_eidolon", "nature_prophet_treant", "undying_zombie", "venomancer_plague_ward",
84
+ "witch_doctor_death_ward", "shadow_shaman_ward", "pugna_nether_ward",
85
+ "templar_assassin_psionic_trap", "techies_mine", "invoker_forge_spirit",
86
+ "warlock_golem", "visage_familiar", "brewmaster_", "phoenix_sun",
87
+ "grimstroke_ink_creature", "hoodwink_sharpshooter",
88
+ )
89
+
90
+
91
+ class EntityType(str, Enum):
92
+ """Dota 2 entity types identified from entity names.
93
+
94
+ Usage:
95
+ # Identify entity type from combat log
96
+ attacker_type = EntityType.from_name(entry.attacker_name)
97
+ if attacker_type == EntityType.HERO:
98
+ print("Hero attacked")
99
+
100
+ # Check if target is a creep
101
+ if EntityType.from_name(entry.target_name).is_creep:
102
+ print("Creep was targeted")
103
+ """
104
+ HERO = "hero"
105
+ LANE_CREEP = "lane_creep"
106
+ NEUTRAL_CREEP = "neutral_creep"
107
+ SUMMON = "summon"
108
+ BUILDING = "building"
109
+ WARD = "ward"
110
+ COURIER = "courier"
111
+ ROSHAN = "roshan"
112
+ UNKNOWN = "unknown"
113
+
114
+ @classmethod
115
+ def from_name(cls, entity_name: str) -> "EntityType":
116
+ """Get EntityType from an entity name string.
117
+
118
+ Args:
119
+ entity_name: Raw entity name from combat log (e.g., "npc_dota_hero_axe")
120
+
121
+ Returns:
122
+ EntityType enum value
123
+ """
124
+ if not entity_name:
125
+ return cls.UNKNOWN
126
+
127
+ name = entity_name.lower()
128
+
129
+ if "npc_dota_hero_" in name:
130
+ return cls.HERO
131
+ if "npc_dota_roshan" in name:
132
+ return cls.ROSHAN
133
+ if "npc_dota_creep_goodguys" in name or "npc_dota_creep_badguys" in name:
134
+ return cls.LANE_CREEP
135
+ if "npc_dota_neutral_" in name:
136
+ return cls.NEUTRAL_CREEP
137
+ if any(x in name for x in ["tower", "barracks", "fort", "filler", "effigy"]):
138
+ return cls.BUILDING
139
+ if "ward" in name and "reward" not in name:
140
+ return cls.WARD
141
+ if "courier" in name:
142
+ return cls.COURIER
143
+ if any(pattern in name for pattern in _SUMMON_PATTERNS):
144
+ return cls.SUMMON
145
+
146
+ return cls.UNKNOWN
147
+
148
+ @property
149
+ def is_hero(self) -> bool:
150
+ """True if this is a hero."""
151
+ return self == EntityType.HERO
152
+
153
+ @property
154
+ def is_creep(self) -> bool:
155
+ """True if this is any type of creep (lane or neutral)."""
156
+ return self in (EntityType.LANE_CREEP, EntityType.NEUTRAL_CREEP)
157
+
158
+ @property
159
+ def is_unit(self) -> bool:
160
+ """True if this is a controllable unit (not building/ward)."""
161
+ return self in (
162
+ EntityType.HERO, EntityType.LANE_CREEP, EntityType.NEUTRAL_CREEP,
163
+ EntityType.SUMMON, EntityType.COURIER, EntityType.ROSHAN
164
+ )
165
+
166
+ @property
167
+ def is_structure(self) -> bool:
168
+ """True if this is a building or ward."""
169
+ return self in (EntityType.BUILDING, EntityType.WARD)
170
+
171
+
172
+ class CombatLogType(int, Enum):
173
+ """Dota 2 combat log event types.
174
+
175
+ Usage:
176
+ # Check combat log entry type
177
+ if entry.type == CombatLogType.DAMAGE:
178
+ print(f"{entry.attacker_name} dealt {entry.value} damage")
179
+
180
+ # Filter by type
181
+ result = parser.parse_combat_log(demo_path, types=[CombatLogType.PURCHASE])
182
+ """
183
+ DAMAGE = 0
184
+ HEAL = 1
185
+ MODIFIER_ADD = 2
186
+ MODIFIER_REMOVE = 3
187
+ DEATH = 4
188
+ ABILITY = 5
189
+ ITEM = 6
190
+ LOCATION = 7
191
+ GOLD = 8
192
+ GAME_STATE = 9
193
+ XP = 10
194
+ PURCHASE = 11
195
+ BUYBACK = 12
196
+ ABILITY_TRIGGER = 13
197
+ PLAYERSTATS = 14
198
+ MULTIKILL = 15
199
+ KILLSTREAK = 16
200
+ TEAM_BUILDING_KILL = 17
201
+ FIRST_BLOOD = 18
202
+ MODIFIER_REFRESH = 19
203
+ NEUTRAL_CAMP_STACK = 20
204
+ PICKUP_RUNE = 21
205
+ REVEALED_INVISIBLE = 22
206
+ HERO_SAVED = 23
207
+ MANA_RESTORED = 24
208
+ HERO_LEVELUP = 25
209
+ BOTTLE_HEAL_ALLY = 26
210
+ ENDGAME_STATS = 27
211
+ INTERRUPT_CHANNEL = 28
212
+ ALLIED_GOLD = 29
213
+ AEGIS_TAKEN = 30
214
+ MANA_DAMAGE = 31
215
+ PHYSICAL_DAMAGE_PREVENTED = 32
216
+ UNIT_SUMMONED = 33
217
+ ATTACK_EVADE = 34
218
+ TREE_CUT = 35
219
+ SUCCESSFUL_SCAN = 36
220
+ END_KILLSTREAK = 37
221
+ BLOODSTONE_CHARGE = 38
222
+ CRITICAL_DAMAGE = 39
223
+ SPELL_ABSORB = 40
224
+ UNIT_TELEPORTED = 41
225
+ KILL_EATER_EVENT = 42
226
+ NEUTRAL_ITEM_EARNED = 43
227
+ TELEPORT_INTERRUPTED = 44
228
+ MODIFIER_STACK_EVENT = 45
229
+
230
+ @property
231
+ def display_name(self) -> str:
232
+ """Human-readable combat log type name."""
233
+ return self.name.replace("_", " ").title()
234
+
235
+ @classmethod
236
+ def from_value(cls, value: int) -> Optional["CombatLogType"]:
237
+ """Get CombatLogType from integer value."""
238
+ for t in cls:
239
+ if t.value == value:
240
+ return t
241
+ return None
242
+
243
+ @property
244
+ def is_damage_related(self) -> bool:
245
+ """True if this type is related to damage/healing."""
246
+ return self in (
247
+ CombatLogType.DAMAGE, CombatLogType.HEAL, CombatLogType.CRITICAL_DAMAGE,
248
+ CombatLogType.MANA_DAMAGE, CombatLogType.PHYSICAL_DAMAGE_PREVENTED
249
+ )
250
+
251
+ @property
252
+ def is_modifier_related(self) -> bool:
253
+ """True if this type is related to buffs/debuffs."""
254
+ return self in (
255
+ CombatLogType.MODIFIER_ADD, CombatLogType.MODIFIER_REMOVE,
256
+ CombatLogType.MODIFIER_REFRESH, CombatLogType.MODIFIER_STACK_EVENT
257
+ )
258
+
259
+ @property
260
+ def is_economy_related(self) -> bool:
261
+ """True if this type is related to gold/XP/items."""
262
+ return self in (
263
+ CombatLogType.GOLD, CombatLogType.XP, CombatLogType.PURCHASE,
264
+ CombatLogType.ALLIED_GOLD, CombatLogType.NEUTRAL_ITEM_EARNED
265
+ )
266
+
267
+ @property
268
+ def is_shield_related(self) -> bool:
269
+ """True if this type is related to shields, barriers, or damage absorption.
270
+
271
+ Note: These events may be rare or not generated in all replays.
272
+ - PHYSICAL_DAMAGE_PREVENTED: Damage block (Vanguard, Crimson Guard, etc.)
273
+ - SPELL_ABSORB: Spell blocked (Linken's Sphere, Lotus Orb, etc.)
274
+ - AEGIS_TAKEN: Aegis of the Immortal picked up
275
+ """
276
+ return self in (
277
+ CombatLogType.PHYSICAL_DAMAGE_PREVENTED,
278
+ CombatLogType.SPELL_ABSORB,
279
+ CombatLogType.AEGIS_TAKEN,
280
+ )
281
+
282
+ @property
283
+ def is_death_related(self) -> bool:
284
+ """True if this type is related to death, kills, or reincarnation.
285
+
286
+ Note: Check `will_reincarnate` field on DEATH events for Aegis/WK respawns.
287
+ """
288
+ return self in (
289
+ CombatLogType.DEATH,
290
+ CombatLogType.FIRST_BLOOD,
291
+ CombatLogType.MULTIKILL,
292
+ CombatLogType.KILLSTREAK,
293
+ CombatLogType.END_KILLSTREAK,
294
+ CombatLogType.TEAM_BUILDING_KILL,
295
+ CombatLogType.BUYBACK,
296
+ )
297
+
298
+ @property
299
+ def is_defensive_related(self) -> bool:
300
+ """True if this type is related to defensive actions or evasion.
301
+
302
+ Includes damage prevention, spell absorption, saves, and evasion.
303
+ """
304
+ return self in (
305
+ CombatLogType.PHYSICAL_DAMAGE_PREVENTED,
306
+ CombatLogType.SPELL_ABSORB,
307
+ CombatLogType.ATTACK_EVADE,
308
+ CombatLogType.HERO_SAVED,
309
+ CombatLogType.REVEALED_INVISIBLE,
310
+ )
311
+
312
+ @property
313
+ def is_ability_related(self) -> bool:
314
+ """True if this type is related to ability usage."""
315
+ return self in (
316
+ CombatLogType.ABILITY,
317
+ CombatLogType.ABILITY_TRIGGER,
318
+ CombatLogType.INTERRUPT_CHANNEL,
319
+ )
320
+
321
+ @property
322
+ def is_movement_related(self) -> bool:
323
+ """True if this type is related to movement or teleportation."""
324
+ return self in (
325
+ CombatLogType.UNIT_TELEPORTED,
326
+ CombatLogType.TELEPORT_INTERRUPTED,
327
+ )
328
+
329
+ @property
330
+ def is_resource_related(self) -> bool:
331
+ """True if this type is related to health/mana resources."""
332
+ return self in (
333
+ CombatLogType.HEAL,
334
+ CombatLogType.MANA_RESTORED,
335
+ CombatLogType.MANA_DAMAGE,
336
+ CombatLogType.BOTTLE_HEAL_ALLY,
337
+ CombatLogType.BLOODSTONE_CHARGE,
338
+ )
339
+
340
+ @property
341
+ def is_unit_related(self) -> bool:
342
+ """True if this type is related to unit spawning or summoning."""
343
+ return self in (
344
+ CombatLogType.UNIT_SUMMONED,
345
+ CombatLogType.NEUTRAL_CAMP_STACK,
346
+ )
347
+
348
+ @classmethod
349
+ def shield_types(cls) -> list["CombatLogType"]:
350
+ """Get all combat log types related to shields/absorption."""
351
+ return [
352
+ cls.PHYSICAL_DAMAGE_PREVENTED,
353
+ cls.SPELL_ABSORB,
354
+ cls.AEGIS_TAKEN,
355
+ ]
356
+
357
+ @classmethod
358
+ def death_types(cls) -> list["CombatLogType"]:
359
+ """Get all combat log types related to death/kills."""
360
+ return [
361
+ cls.DEATH,
362
+ cls.FIRST_BLOOD,
363
+ cls.MULTIKILL,
364
+ cls.KILLSTREAK,
365
+ cls.END_KILLSTREAK,
366
+ cls.TEAM_BUILDING_KILL,
367
+ cls.BUYBACK,
368
+ ]
369
+
370
+
371
+ class DamageType(int, Enum):
372
+ """Dota 2 damage types.
373
+
374
+ Usage:
375
+ if entry.damage_type == DamageType.PURE:
376
+ print("Pure damage - ignores armor and magic resistance")
377
+ """
378
+ PHYSICAL = 0
379
+ MAGICAL = 1
380
+ PURE = 2
381
+ COMPOSITE = 3 # Legacy: removed from Dota 2, was reduced by both armor and magic resistance
382
+ HP_REMOVAL = 4
383
+
384
+ @property
385
+ def display_name(self) -> str:
386
+ """Human-readable damage type name."""
387
+ return self.name.title()
388
+
389
+ @classmethod
390
+ def from_value(cls, value: int) -> Optional["DamageType"]:
391
+ """Get DamageType from integer value."""
392
+ for t in cls:
393
+ if t.value == value:
394
+ return t
395
+ return None
396
+
397
+
398
+ class Team(int, Enum):
399
+ """Dota 2 team identifiers.
400
+
401
+ Usage:
402
+ if entry.attacker_team == Team.RADIANT:
403
+ print("Radiant team attacked")
404
+ """
405
+ SPECTATOR = 0
406
+ UNASSIGNED = 1
407
+ RADIANT = 2
408
+ DIRE = 3
409
+ NEUTRAL = 4
410
+
411
+ @property
412
+ def display_name(self) -> str:
413
+ """Human-readable team name."""
414
+ return self.name.title()
415
+
416
+ @property
417
+ def is_playing(self) -> bool:
418
+ """True if this is an actual playing team (not spectator/unassigned)."""
419
+ return self in (Team.RADIANT, Team.DIRE)
420
+
421
+ @property
422
+ def is_neutral(self) -> bool:
423
+ """True if this is a neutral unit (creeps, Roshan, etc.)."""
424
+ return self == Team.NEUTRAL
425
+
426
+ @classmethod
427
+ def from_value(cls, value: int) -> Optional["Team"]:
428
+ """Get Team from integer value."""
429
+ for t in cls:
430
+ if t.value == value:
431
+ return t
432
+ return None
433
+
434
+ @property
435
+ def opposite(self) -> Optional["Team"]:
436
+ """Get the opposing team. Returns None for non-playing teams."""
437
+ if self == Team.RADIANT:
438
+ return Team.DIRE
439
+ elif self == Team.DIRE:
440
+ return Team.RADIANT
441
+ return None
442
+
443
+
444
+ class NeutralCampType(int, Enum):
445
+ """Neutral creep camp types.
446
+
447
+ Used in combat log events (DEATH, MODIFIER_ADD, etc.) to identify
448
+ which type of neutral camp a creep belongs to.
449
+
450
+ Note: SMALL (0) is also used for non-neutral units (lane creeps, wards).
451
+ Filter by target_name containing "neutral" to get only neutral creeps.
452
+
453
+ Usage:
454
+ if entry.neutral_camp_type == NeutralCampType.ANCIENT:
455
+ print("Ancient camp creep killed")
456
+
457
+ # Detect multi-camp farming (filter for neutrals first)
458
+ neutral_deaths = [e for e in deaths if "neutral" in e.target_name]
459
+ camp_types = {e.neutral_camp_type for e in neutral_deaths}
460
+ if len(camp_types) >= 2:
461
+ print("Multi-camp farming detected!")
462
+ """
463
+ SMALL = 0 # Small camps: kobolds, harpies, ghosts, forest trolls, gnolls (also default for non-neutrals)
464
+ MEDIUM = 1 # Medium camps: wolves, ogres, mud golems
465
+ HARD = 2 # Hard/Large camps: hellbears, dark trolls, wildkin, satyr hellcaller, centaurs
466
+ ANCIENT = 3 # Ancient camps: dragons, thunderhides, prowlers, rock golems
467
+
468
+ @property
469
+ def display_name(self) -> str:
470
+ """Human-readable camp type name."""
471
+ names = {
472
+ 0: "Small Camp",
473
+ 1: "Medium Camp",
474
+ 2: "Hard Camp",
475
+ 3: "Ancient Camp",
476
+ }
477
+ return names.get(self.value, "Unknown")
478
+
479
+ @property
480
+ def is_ancient(self) -> bool:
481
+ """True if this is an ancient camp."""
482
+ return self == NeutralCampType.ANCIENT
483
+
484
+ @classmethod
485
+ def from_value(cls, value: int) -> "NeutralCampType":
486
+ """Get NeutralCampType from integer value."""
487
+ for t in cls:
488
+ if t.value == value:
489
+ return t
490
+ return cls.SMALL
491
+
492
+
493
+ class Hero(int, Enum):
494
+ """Dota 2 hero IDs."""
495
+ ANTI_MAGE = 1
496
+ AXE = 2
497
+ BANE = 3
498
+ BLOODSEEKER = 4
499
+ CRYSTAL_MAIDEN = 5
500
+ DROW_RANGER = 6
501
+ EARTHSHAKER = 7
502
+ JUGGERNAUT = 8
503
+ MIRANA = 9
504
+ MORPHLING = 10
505
+ SHADOW_FIEND = 11
506
+ PHANTOM_LANCER = 12
507
+ PUCK = 13
508
+ PUDGE = 14
509
+ RAZOR = 15
510
+ SAND_KING = 16
511
+ STORM_SPIRIT = 17
512
+ SVEN = 18
513
+ TINY = 19
514
+ VENGEFUL_SPIRIT = 20
515
+ WINDRANGER = 21
516
+ ZEUS = 22
517
+ KUNKKA = 23
518
+ LINA = 25
519
+ LION = 26
520
+ SHADOW_SHAMAN = 27
521
+ SLARDAR = 28
522
+ TIDEHUNTER = 29
523
+ WITCH_DOCTOR = 30
524
+ LICH = 31
525
+ RIKI = 32
526
+ ENIGMA = 33
527
+ TINKER = 34
528
+ SNIPER = 35
529
+ NECROPHOS = 36
530
+ WARLOCK = 37
531
+ BEASTMASTER = 38
532
+ QUEEN_OF_PAIN = 39
533
+ VENOMANCER = 40
534
+ FACELESS_VOID = 41
535
+ WRAITH_KING = 42
536
+ DEATH_PROPHET = 43
537
+ PHANTOM_ASSASSIN = 44
538
+ PUGNA = 45
539
+ TEMPLAR_ASSASSIN = 46
540
+ VIPER = 47
541
+ LUNA = 48
542
+ DRAGON_KNIGHT = 49
543
+ DAZZLE = 50
544
+ CLOCKWERK = 51
545
+ LESHRAC = 52
546
+ NATURES_PROPHET = 53
547
+ LIFESTEALER = 54
548
+ DARK_SEER = 55
549
+ CLINKZ = 56
550
+ OMNIKNIGHT = 57
551
+ ENCHANTRESS = 58
552
+ HUSKAR = 59
553
+ NIGHT_STALKER = 60
554
+ BROODMOTHER = 61
555
+ BOUNTY_HUNTER = 62
556
+ WEAVER = 63
557
+ JAKIRO = 64
558
+ BATRIDER = 65
559
+ CHEN = 66
560
+ SPECTRE = 67
561
+ ANCIENT_APPARITION = 68
562
+ DOOM = 69
563
+ URSA = 70
564
+ SPIRIT_BREAKER = 71
565
+ GYROCOPTER = 72
566
+ ALCHEMIST = 73
567
+ INVOKER = 74
568
+ SILENCER = 75
569
+ OUTWORLD_DESTROYER = 76
570
+ LYCAN = 77
571
+ BREWMASTER = 78
572
+ SHADOW_DEMON = 79
573
+ LONE_DRUID = 80
574
+ CHAOS_KNIGHT = 81
575
+ MEEPO = 82
576
+ TREANT_PROTECTOR = 83
577
+ OGRE_MAGI = 84
578
+ UNDYING = 85
579
+ RUBICK = 86
580
+ DISRUPTOR = 87
581
+ NYX_ASSASSIN = 88
582
+ NAGA_SIREN = 89
583
+ KEEPER_OF_THE_LIGHT = 90
584
+ IO = 91
585
+ VISAGE = 92
586
+ SLARK = 93
587
+ MEDUSA = 94
588
+ TROLL_WARLORD = 95
589
+ CENTAUR_WARRUNNER = 96
590
+ MAGNUS = 97
591
+ TIMBERSAW = 98
592
+ BRISTLEBACK = 99
593
+ TUSK = 100
594
+ SKYWRATH_MAGE = 101
595
+ ABADDON = 102
596
+ ELDER_TITAN = 103
597
+ LEGION_COMMANDER = 104
598
+ TECHIES = 105
599
+ EMBER_SPIRIT = 106
600
+ EARTH_SPIRIT = 107
601
+ UNDERLORD = 108
602
+ TERRORBLADE = 109
603
+ PHOENIX = 110
604
+ ORACLE = 111
605
+ WINTER_WYVERN = 112
606
+ ARC_WARDEN = 113
607
+ MONKEY_KING = 114
608
+ DARK_WILLOW = 119
609
+ PANGOLIER = 120
610
+ GRIMSTROKE = 121
611
+ HOODWINK = 123
612
+ VOID_SPIRIT = 126
613
+ SNAPFIRE = 128
614
+ MARS = 129
615
+ DAWNBREAKER = 135
616
+ MARCI = 136
617
+ PRIMAL_BEAST = 137
618
+ MUERTA = 138
619
+ RINGMASTER = 145
620
+
621
+ @classmethod
622
+ def from_id(cls, hero_id: int) -> Optional["Hero"]:
623
+ """Get Hero from integer ID."""
624
+ for hero in cls:
625
+ if hero.value == hero_id:
626
+ return hero
627
+ return None
628
+
629
+ @property
630
+ def display_name(self) -> str:
631
+ """Human-readable hero name."""
632
+ return self.name.replace("_", " ").title()
633
+
634
+
635
+ class NeutralItemTier(int, Enum):
636
+ """Neutral item tier classification.
637
+
638
+ Tiers unlock at specific game times:
639
+ - Tier 1: 5:00 (was 7:00 before 7.39d)
640
+ - Tier 2: 15:00 (was 17:00)
641
+ - Tier 3: 25:00 (was 27:00)
642
+ - Tier 4: 35:00 (was 37:00)
643
+ - Tier 5: 55:00 (was 60:00)
644
+ """
645
+ TIER_1 = 0
646
+ TIER_2 = 1
647
+ TIER_3 = 2
648
+ TIER_4 = 3
649
+ TIER_5 = 4
650
+
651
+ @property
652
+ def display_name(self) -> str:
653
+ """Human-readable tier name."""
654
+ return f"Tier {self.value + 1}"
655
+
656
+ @property
657
+ def unlock_time_minutes(self) -> int:
658
+ """Game time in minutes when this tier unlocks (patch 7.39d+)."""
659
+ times = {0: 5, 1: 15, 2: 25, 3: 35, 4: 55}
660
+ return times[self.value]
661
+
662
+ @classmethod
663
+ def from_value(cls, value: int) -> Optional["NeutralItemTier"]:
664
+ """Get NeutralItemTier from integer value (0-4)."""
665
+ for t in cls:
666
+ if t.value == value:
667
+ return t
668
+ return None
669
+
670
+
671
+ # All neutral items with their internal names and tiers
672
+ # Includes both active items and retired/rotated items from previous patches
673
+ _NEUTRAL_ITEMS_DATA = {
674
+ # === TIER 1 (Current 7.38+) ===
675
+ "item_chipped_vest": (0, "Chipped Vest"),
676
+ "item_dormant_curio": (0, "Dormant Curio"),
677
+ "item_kobold_cup": (0, "Kobold Cup"),
678
+ "item_occult_bracelet": (0, "Occult Bracelet"),
679
+ "item_pollywog_charm": (0, "Pollywog Charm"),
680
+ "item_rippers_lash": (0, "Ripper's Lash"),
681
+ "item_sisters_shroud": (0, "Sister's Shroud"),
682
+ "item_spark_of_courage": (0, "Spark of Courage"),
683
+ # Tier 1 - Retired/Rotated
684
+ "item_arcane_ring": (0, "Arcane Ring"),
685
+ "item_broom_handle": (0, "Broom Handle"),
686
+ "item_duelist_gloves": (0, "Duelist Gloves"),
687
+ "item_faded_broach": (0, "Faded Broach"),
688
+ "item_fairys_trinket": (0, "Fairy's Trinket"),
689
+ "item_ironwood_tree": (0, "Ironwood Tree"),
690
+ "item_keen_optic": (0, "Keen Optic"),
691
+ "item_lance_of_pursuit": (0, "Lance of Pursuit"),
692
+ "item_mango_tree": (0, "Mango Tree"),
693
+ "item_ocean_heart": (0, "Ocean Heart"),
694
+ "item_pig_pole": (0, "Pig Pole"),
695
+ "item_possessed_mask": (0, "Possessed Mask"),
696
+ "item_royal_jelly": (0, "Royal Jelly"),
697
+ "item_safety_bubble": (0, "Safety Bubble"),
698
+ "item_seeds_of_serenity": (0, "Seeds of Serenity"),
699
+ "item_trusty_shovel": (0, "Trusty Shovel"),
700
+
701
+ # === TIER 2 (Current 7.38+) ===
702
+ "item_brigands_blade": (1, "Brigand's Blade"),
703
+ "item_essence_ring": (1, "Essence Ring"),
704
+ "item_mana_draught": (1, "Mana Draught"),
705
+ "item_poor_mans_shield": (1, "Poor Man's Shield"),
706
+ "item_searing_signet": (1, "Searing Signet"),
707
+ "item_tumblers_toy": (1, "Tumbler's Toy"),
708
+ # Tier 2 - Retired/Rotated
709
+ "item_bullwhip": (1, "Bullwhip"),
710
+ "item_clumsy_net": (1, "Clumsy Net"),
711
+ "item_dagger_of_ristul": (1, "Dagger of Ristul"),
712
+ "item_dragon_scale": (1, "Dragon Scale"),
713
+ "item_eye_of_the_vizier": (1, "Eye of the Vizier"),
714
+ "item_fae_grenade": (1, "Fae Grenade"),
715
+ "item_gossamer_cape": (1, "Gossamer Cape"),
716
+ "item_grove_bow": (1, "Grove Bow"),
717
+ "item_imp_claw": (1, "Imp Claw"),
718
+ "item_iron_talon": (1, "Iron Talon"),
719
+ "item_light_collector": (1, "Light Collector"),
720
+ "item_nether_shawl": (1, "Nether Shawl"),
721
+ "item_orb_of_destruction": (1, "Orb of Destruction"),
722
+ "item_philosophers_stone": (1, "Philosopher's Stone"),
723
+ "item_pupils_gift": (1, "Pupil's Gift"),
724
+ "item_quicksilver_amulet": (1, "Quicksilver Amulet"),
725
+ "item_ring_of_aquila": (1, "Ring of Aquila"),
726
+ "item_specialists_array": (1, "Specialist's Array"),
727
+ "item_vambrace": (1, "Vambrace"),
728
+ "item_vampire_fangs": (1, "Vampire Fangs"),
729
+
730
+ # === TIER 3 (Current 7.38+) ===
731
+ "item_gale_guard": (2, "Gale Guard"),
732
+ "item_gunpowder_gauntlet": (2, "Gunpowder Gauntlet"),
733
+ "item_jidi_pollen_bag": (2, "Jidi Pollen Bag"),
734
+ "item_psychic_headband": (2, "Psychic Headband"),
735
+ "item_serrated_shiv": (2, "Serrated Shiv"),
736
+ "item_whisper_of_the_dread": (2, "Whisper of the Dread"),
737
+ # Tier 3 - Retired/Rotated
738
+ "item_ceremonial_robe": (2, "Ceremonial Robe"),
739
+ "item_cloak_of_flames": (2, "Cloak of Flames"),
740
+ "item_craggy_coat": (2, "Craggy Coat"),
741
+ "item_dandelion_amulet": (2, "Dandelion Amulet"),
742
+ "item_defiant_shell": (2, "Defiant Shell"),
743
+ "item_doubloon": (2, "Doubloon"),
744
+ "item_elven_tunic": (2, "Elven Tunic"),
745
+ "item_enchanted_quiver": (2, "Enchanted Quiver"),
746
+ "item_nemesis_curse": (2, "Nemesis Curse"),
747
+ "item_ogre_seal_totem": (2, "Ogre Seal Totem"),
748
+ "item_paladin_sword": (2, "Paladin Sword"),
749
+ "item_quickening_charm": (2, "Quickening Charm"),
750
+ "item_spider_legs": (2, "Spider Legs"),
751
+ "item_titan_sliver": (2, "Titan Sliver"),
752
+ "item_tome_of_aghanim": (2, "Tome of Aghanim"),
753
+ "item_vindicators_axe": (2, "Vindicator's Axe"),
754
+
755
+ # === TIER 4 (Current 7.38+) ===
756
+ "item_crippling_crossbow": (3, "Crippling Crossbow"),
757
+ "item_dezun_bloodrite": (3, "Dezun Bloodrite"),
758
+ "item_giants_maul": (3, "Giant's Maul"),
759
+ "item_magnifying_monocle": (3, "Magnifying Monocle"),
760
+ "item_outworld_staff": (3, "Outworld Staff"),
761
+ "item_pyrrhic_cloak": (3, "Pyrrhic Cloak"),
762
+ # Tier 4 - Retired/Rotated
763
+ "item_ancient_guardian": (3, "Ancient Guardian"),
764
+ "item_ascetics_cap": (3, "Ascetic's Cap"),
765
+ "item_avianas_feather": (3, "Aviana's Feather"),
766
+ "item_flicker": (3, "Flicker"),
767
+ "item_havoc_hammer": (3, "Havoc Hammer"),
768
+ "item_illusionists_cape": (3, "Illusionist's Cape"),
769
+ "item_martyrs_plate": (3, "Martyr's Plate"),
770
+ "item_mind_breaker": (3, "Mind Breaker"),
771
+ "item_ninja_gear": (3, "Ninja Gear"),
772
+ "item_penta_edged_sword": (3, "Penta-edged Sword"),
773
+ "item_princes_knife": (3, "Prince's Knife"),
774
+ "item_rattlecage": (3, "Rattlecage"),
775
+ "item_spell_prism": (3, "Spell Prism"),
776
+ "item_stormcrafter": (3, "Stormcrafter"),
777
+ "item_telescope": (3, "Telescope"),
778
+ "item_timeless_relic": (3, "Timeless Relic"),
779
+ "item_trickster_cloak": (3, "Trickster Cloak"),
780
+ "item_witchbane": (3, "Witchbane"),
781
+
782
+ # === TIER 5 (Current 7.38+) ===
783
+ "item_book_of_the_dead": (4, "Book of the Dead"),
784
+ "item_divine_regalia": (4, "Divine Regalia"),
785
+ "item_fallen_sky": (4, "Fallen Sky"),
786
+ "item_helm_of_the_undying": (4, "Helm of the Undying"),
787
+ "item_minotaur_horn": (4, "Minotaur Horn"),
788
+ "item_spider_legs_tier5": (4, "Spider Legs"),
789
+ "item_stygian_desolator": (4, "Stygian Desolator"),
790
+ "item_unrelenting_eye": (4, "Unrelenting Eye"),
791
+ # Tier 5 - Retired/Rotated
792
+ "item_apex": (4, "Apex"),
793
+ "item_arcanists_armor": (4, "Arcanist's Armor"),
794
+ "item_ballista": (4, "Ballista"),
795
+ "item_book_of_shadows": (4, "Book of Shadows"),
796
+ "item_demonicon": (4, "Demonicon"),
797
+ "item_ex_machina": (4, "Ex Machina"),
798
+ "item_force_boots": (4, "Force Boots"),
799
+ "item_fusion_rune": (4, "Fusion Rune"),
800
+ "item_giants_ring": (4, "Giant's Ring"),
801
+ "item_magic_lamp": (4, "Magic Lamp"),
802
+ "item_mirror_shield": (4, "Mirror Shield"),
803
+ "item_phoenix_ash": (4, "Phoenix Ash"),
804
+ "item_pirate_hat": (4, "Pirate Hat"),
805
+ "item_seer_stone": (4, "Seer Stone"),
806
+ "item_the_leveller": (4, "The Leveller"),
807
+ "item_trident": (4, "Trident"),
808
+ "item_unwavering_condition": (4, "Unwavering Condition"),
809
+ "item_witless_shako": (4, "Witless Shako"),
810
+ "item_woodland_striders": (4, "Woodland Striders"),
811
+
812
+ # === SPECIAL / CRAFTING SYSTEM ===
813
+ "item_madstone_bundle": (None, "Madstone Bundle"), # Crafting currency
814
+ }
815
+
816
+
817
+ class NeutralItem(str, Enum):
818
+ """All Dota 2 neutral items (active and retired).
819
+
820
+ Usage:
821
+ # Check if an item is a neutral item
822
+ if NeutralItem.is_neutral_item(entry.inflictor_name):
823
+ item = NeutralItem.from_item_name(entry.inflictor_name)
824
+ print(f"Neutral item: {item.display_name} (Tier {item.tier + 1})")
825
+
826
+ # Get all tier 1 items
827
+ tier1 = NeutralItem.items_by_tier(0)
828
+ """
829
+ # Tier 1 - Current
830
+ CHIPPED_VEST = "item_chipped_vest"
831
+ DORMANT_CURIO = "item_dormant_curio"
832
+ KOBOLD_CUP = "item_kobold_cup"
833
+ OCCULT_BRACELET = "item_occult_bracelet"
834
+ POLLYWOG_CHARM = "item_pollywog_charm"
835
+ RIPPERS_LASH = "item_rippers_lash"
836
+ SISTERS_SHROUD = "item_sisters_shroud"
837
+ SPARK_OF_COURAGE = "item_spark_of_courage"
838
+ # Tier 1 - Retired
839
+ ARCANE_RING = "item_arcane_ring"
840
+ BROOM_HANDLE = "item_broom_handle"
841
+ DUELIST_GLOVES = "item_duelist_gloves"
842
+ FADED_BROACH = "item_faded_broach"
843
+ FAIRYS_TRINKET = "item_fairys_trinket"
844
+ IRONWOOD_TREE = "item_ironwood_tree"
845
+ KEEN_OPTIC = "item_keen_optic"
846
+ LANCE_OF_PURSUIT = "item_lance_of_pursuit"
847
+ MANGO_TREE = "item_mango_tree"
848
+ OCEAN_HEART = "item_ocean_heart"
849
+ PIG_POLE = "item_pig_pole"
850
+ POSSESSED_MASK = "item_possessed_mask"
851
+ ROYAL_JELLY = "item_royal_jelly"
852
+ SAFETY_BUBBLE = "item_safety_bubble"
853
+ SEEDS_OF_SERENITY = "item_seeds_of_serenity"
854
+ TRUSTY_SHOVEL = "item_trusty_shovel"
855
+
856
+ # Tier 2 - Current
857
+ BRIGANDS_BLADE = "item_brigands_blade"
858
+ ESSENCE_RING = "item_essence_ring"
859
+ MANA_DRAUGHT = "item_mana_draught"
860
+ POOR_MANS_SHIELD = "item_poor_mans_shield"
861
+ SEARING_SIGNET = "item_searing_signet"
862
+ TUMBLERS_TOY = "item_tumblers_toy"
863
+ # Tier 2 - Retired
864
+ BULLWHIP = "item_bullwhip"
865
+ CLUMSY_NET = "item_clumsy_net"
866
+ DAGGER_OF_RISTUL = "item_dagger_of_ristul"
867
+ DRAGON_SCALE = "item_dragon_scale"
868
+ EYE_OF_THE_VIZIER = "item_eye_of_the_vizier"
869
+ FAE_GRENADE = "item_fae_grenade"
870
+ GOSSAMER_CAPE = "item_gossamer_cape"
871
+ GROVE_BOW = "item_grove_bow"
872
+ IMP_CLAW = "item_imp_claw"
873
+ IRON_TALON = "item_iron_talon"
874
+ LIGHT_COLLECTOR = "item_light_collector"
875
+ NETHER_SHAWL = "item_nether_shawl"
876
+ ORB_OF_DESTRUCTION = "item_orb_of_destruction"
877
+ PHILOSOPHERS_STONE = "item_philosophers_stone"
878
+ PUPILS_GIFT = "item_pupils_gift"
879
+ QUICKSILVER_AMULET = "item_quicksilver_amulet"
880
+ RING_OF_AQUILA = "item_ring_of_aquila"
881
+ SPECIALISTS_ARRAY = "item_specialists_array"
882
+ VAMBRACE = "item_vambrace"
883
+ VAMPIRE_FANGS = "item_vampire_fangs"
884
+
885
+ # Tier 3 - Current
886
+ GALE_GUARD = "item_gale_guard"
887
+ GUNPOWDER_GAUNTLET = "item_gunpowder_gauntlet"
888
+ JIDI_POLLEN_BAG = "item_jidi_pollen_bag"
889
+ PSYCHIC_HEADBAND = "item_psychic_headband"
890
+ SERRATED_SHIV = "item_serrated_shiv"
891
+ WHISPER_OF_THE_DREAD = "item_whisper_of_the_dread"
892
+ # Tier 3 - Retired
893
+ CEREMONIAL_ROBE = "item_ceremonial_robe"
894
+ CLOAK_OF_FLAMES = "item_cloak_of_flames"
895
+ CRAGGY_COAT = "item_craggy_coat"
896
+ DANDELION_AMULET = "item_dandelion_amulet"
897
+ DEFIANT_SHELL = "item_defiant_shell"
898
+ DOUBLOON = "item_doubloon"
899
+ ELVEN_TUNIC = "item_elven_tunic"
900
+ ENCHANTED_QUIVER = "item_enchanted_quiver"
901
+ NEMESIS_CURSE = "item_nemesis_curse"
902
+ OGRE_SEAL_TOTEM = "item_ogre_seal_totem"
903
+ PALADIN_SWORD = "item_paladin_sword"
904
+ QUICKENING_CHARM = "item_quickening_charm"
905
+ SPIDER_LEGS = "item_spider_legs"
906
+ TITAN_SLIVER = "item_titan_sliver"
907
+ TOME_OF_AGHANIM = "item_tome_of_aghanim"
908
+ VINDICATORS_AXE = "item_vindicators_axe"
909
+
910
+ # Tier 4 - Current
911
+ CRIPPLING_CROSSBOW = "item_crippling_crossbow"
912
+ DEZUN_BLOODRITE = "item_dezun_bloodrite"
913
+ GIANTS_MAUL = "item_giants_maul"
914
+ MAGNIFYING_MONOCLE = "item_magnifying_monocle"
915
+ OUTWORLD_STAFF = "item_outworld_staff"
916
+ PYRRHIC_CLOAK = "item_pyrrhic_cloak"
917
+ # Tier 4 - Retired
918
+ ANCIENT_GUARDIAN = "item_ancient_guardian"
919
+ ASCETICS_CAP = "item_ascetics_cap"
920
+ AVIANAS_FEATHER = "item_avianas_feather"
921
+ FLICKER = "item_flicker"
922
+ HAVOC_HAMMER = "item_havoc_hammer"
923
+ ILLUSIONISTS_CAPE = "item_illusionists_cape"
924
+ MARTYRS_PLATE = "item_martyrs_plate"
925
+ MIND_BREAKER = "item_mind_breaker"
926
+ NINJA_GEAR = "item_ninja_gear"
927
+ PENTA_EDGED_SWORD = "item_penta_edged_sword"
928
+ PRINCES_KNIFE = "item_princes_knife"
929
+ RATTLECAGE = "item_rattlecage"
930
+ SPELL_PRISM = "item_spell_prism"
931
+ STORMCRAFTER = "item_stormcrafter"
932
+ TELESCOPE = "item_telescope"
933
+ TIMELESS_RELIC = "item_timeless_relic"
934
+ TRICKSTER_CLOAK = "item_trickster_cloak"
935
+ WITCHBANE = "item_witchbane"
936
+
937
+ # Tier 5 - Current
938
+ BOOK_OF_THE_DEAD = "item_book_of_the_dead"
939
+ DIVINE_REGALIA = "item_divine_regalia"
940
+ FALLEN_SKY = "item_fallen_sky"
941
+ HELM_OF_THE_UNDYING = "item_helm_of_the_undying"
942
+ MINOTAUR_HORN = "item_minotaur_horn"
943
+ SPIDER_LEGS_T5 = "item_spider_legs_tier5"
944
+ STYGIAN_DESOLATOR = "item_stygian_desolator"
945
+ UNRELENTING_EYE = "item_unrelenting_eye"
946
+ # Tier 5 - Retired
947
+ APEX = "item_apex"
948
+ ARCANISTS_ARMOR = "item_arcanists_armor"
949
+ BALLISTA = "item_ballista"
950
+ BOOK_OF_SHADOWS = "item_book_of_shadows"
951
+ DEMONICON = "item_demonicon"
952
+ EX_MACHINA = "item_ex_machina"
953
+ FORCE_BOOTS = "item_force_boots"
954
+ FUSION_RUNE = "item_fusion_rune"
955
+ GIANTS_RING = "item_giants_ring"
956
+ MAGIC_LAMP = "item_magic_lamp"
957
+ MIRROR_SHIELD = "item_mirror_shield"
958
+ PHOENIX_ASH = "item_phoenix_ash"
959
+ PIRATE_HAT = "item_pirate_hat"
960
+ SEER_STONE = "item_seer_stone"
961
+ THE_LEVELLER = "item_the_leveller"
962
+ TRIDENT = "item_trident"
963
+ UNWAVERING_CONDITION = "item_unwavering_condition"
964
+ WITLESS_SHAKO = "item_witless_shako"
965
+ WOODLAND_STRIDERS = "item_woodland_striders"
966
+
967
+ # Special
968
+ MADSTONE_BUNDLE = "item_madstone_bundle"
969
+
970
+ @property
971
+ def item_name(self) -> str:
972
+ """Internal item name (e.g., 'item_kobold_cup')."""
973
+ return self.value
974
+
975
+ @property
976
+ def display_name(self) -> str:
977
+ """Human-readable item name."""
978
+ data = _NEUTRAL_ITEMS_DATA.get(self.value)
979
+ return data[1] if data else self.name.replace("_", " ").title()
980
+
981
+ @property
982
+ def tier(self) -> Optional[int]:
983
+ """Item tier (0-4) or None for special items like Madstone."""
984
+ data = _NEUTRAL_ITEMS_DATA.get(self.value)
985
+ return data[0] if data else None
986
+
987
+ @property
988
+ def tier_enum(self) -> Optional[NeutralItemTier]:
989
+ """Item tier as NeutralItemTier enum."""
990
+ t = self.tier
991
+ return NeutralItemTier.from_value(t) if t is not None else None
992
+
993
+ @classmethod
994
+ def from_item_name(cls, item_name: str) -> Optional["NeutralItem"]:
995
+ """Get NeutralItem from internal item name."""
996
+ for item in cls:
997
+ if item.value == item_name:
998
+ return item
999
+ return None
1000
+
1001
+ @classmethod
1002
+ def is_neutral_item(cls, item_name: str) -> bool:
1003
+ """Check if an item name is a neutral item."""
1004
+ return item_name in _NEUTRAL_ITEMS_DATA
1005
+
1006
+ @classmethod
1007
+ def items_by_tier(cls, tier: int) -> List["NeutralItem"]:
1008
+ """Get all neutral items of a specific tier."""
1009
+ return [
1010
+ item for item in cls
1011
+ if _NEUTRAL_ITEMS_DATA.get(item.value, (None,))[0] == tier
1012
+ ]
1013
+
1014
+ @classmethod
1015
+ def all_item_names(cls) -> List[str]:
1016
+ """Get all neutral item internal names."""
1017
+ return list(_NEUTRAL_ITEMS_DATA.keys())
1018
+
1019
+
1020
+ class ChatWheelMessage(int, Enum):
1021
+ """Dota 2 chat wheel message IDs.
1022
+
1023
+ Standard phrases (IDs 0-232) are available to all players.
1024
+ IDs 11000+ are Dota Plus hero voice lines.
1025
+ IDs 120000+ are TI Battle Pass voice lines.
1026
+ IDs 401000+ are TI talent/team voice lines.
1027
+
1028
+ Usage:
1029
+ msg = ChatWheelMessage.from_id(chat_message_id)
1030
+ if msg:
1031
+ print(f"Voice line: {msg.display_name}")
1032
+ """
1033
+ # Basic phrases
1034
+ OK = 0
1035
+ CAREFUL = 1
1036
+ GET_BACK = 2
1037
+ NEED_WARDS = 3
1038
+ STUN_NOW = 4
1039
+ HELP = 5
1040
+ PUSH_NOW = 6
1041
+ WELL_PLAYED = 7
1042
+ MISSING = 8
1043
+ MISSING_TOP = 9
1044
+ MISSING_MID = 10
1045
+ MISSING_BOTTOM = 11
1046
+ GO = 12
1047
+ INITIATE = 13
1048
+ FOLLOW_ME = 14
1049
+ GROUP_UP = 15
1050
+ SPREAD_OUT = 16
1051
+ SPLIT_FARM = 17
1052
+ ATTACK_NOW = 18
1053
+ # Combat/Cooldowns
1054
+ ON_MY_WAY = 22
1055
+ HEAL = 24
1056
+ MANA = 25
1057
+ OUT_OF_MANA = 26
1058
+ COOLDOWN = 27
1059
+ # Enemy/Lane info
1060
+ ENEMY_RETURNED = 30
1061
+ ALL_MISSING = 31
1062
+ ENEMY_INCOMING = 32
1063
+ ENEMY_INVIS = 33
1064
+ # Items/Neutral
1065
+ CHECK_RUNES = 40
1066
+ ROSHAN = 41
1067
+ AFFIRMATIVE = 54
1068
+ WAIT = 55
1069
+ DIVE = 56
1070
+ ENEMY_HAS_RUNE = 57
1071
+ SPLIT_PUSH = 58
1072
+ COMING_TO_GANK = 59
1073
+ REQUESTING_GANK = 60
1074
+ # Misc
1075
+ THANKS = 62
1076
+ SORRY = 63
1077
+ DONT_GIVE_UP = 64
1078
+ THAT_JUST_HAPPENED = 65
1079
+ NICE = 66
1080
+ NEW_META = 67
1081
+ MY_BAD = 68
1082
+ REGRET = 69
1083
+ RELAX = 70
1084
+ SPACE_CREATED = 71
1085
+ GGWP = 72
1086
+ GAME_IS_HARD = 73
1087
+ # Additional
1088
+ IM_RETREATING = 78
1089
+ GOOD_LUCK = 79
1090
+ UH_OH = 82
1091
+ WOW = 86
1092
+ PATIENCE = 224
1093
+ CRYBABY = 229
1094
+ BRUTAL_SAVAGE_REKT = 230
1095
+ NOT_YET = 232
1096
+
1097
+ @property
1098
+ def display_name(self) -> str:
1099
+ """Human-readable message text."""
1100
+ names = {
1101
+ 0: "Okay", 1: "Careful!", 2: "Get Back!", 3: "We need wards",
1102
+ 4: "Stun now!", 5: "Help!", 6: "Push now", 7: "Well played!",
1103
+ 8: "Missing!", 9: "Missing top!", 10: "Missing mid!", 11: "Missing bottom!",
1104
+ 12: "Go!", 13: "Initiate!", 14: "Follow me", 15: "Group up",
1105
+ 16: "Spread out", 17: "Split up and farm", 18: "Attack now!",
1106
+ 22: "On my way", 24: "Heal", 25: "Mana", 26: "Out of mana",
1107
+ 27: "Cooldown", 30: "Enemy returned", 31: "All enemy heroes missing!",
1108
+ 32: "Enemy incoming!", 33: "Invisible enemy nearby!",
1109
+ 40: "Check runes", 41: "Roshan", 54: "Affirmative", 55: "Wait",
1110
+ 56: "Dive!", 57: "Enemy has rune", 58: "Split push",
1111
+ 59: "Coming to gank", 60: "Requesting a gank", 62: "Thanks!",
1112
+ 63: "Sorry", 64: "Don't give up!", 65: "That just happened",
1113
+ 66: "Nice", 67: "New Meta", 68: "My bad", 69: "I immediately regret my decision",
1114
+ 70: "Relax, you're doing fine", 71: "> Space created",
1115
+ 72: "GG, well played", 73: "Game is hard", 78: "I'm retreating",
1116
+ 79: "Good luck, have fun", 82: "Uh oh", 86: "Wow",
1117
+ 224: "Patience from Zhou", 229: "Crybaby", 230: "Brutal. Savage. Rekt.",
1118
+ 232: "Not yet"
1119
+ }
1120
+ return names.get(self.value, f"Voice Line #{self.value}")
1121
+
1122
+ @classmethod
1123
+ def from_id(cls, message_id: int) -> Optional["ChatWheelMessage"]:
1124
+ """Get ChatWheelMessage from message ID. Returns None for unmapped IDs."""
1125
+ for msg in cls:
1126
+ if msg.value == message_id:
1127
+ return msg
1128
+ return None
1129
+
1130
+ @classmethod
1131
+ def describe_id(cls, message_id: int) -> str:
1132
+ """Get description for any message ID, including unmapped ones."""
1133
+ msg = cls.from_id(message_id)
1134
+ if msg:
1135
+ return msg.display_name
1136
+ if 11000 <= message_id < 12000:
1137
+ return f"Dota Plus Hero Voice Line #{message_id}"
1138
+ if 120000 <= message_id < 130000:
1139
+ return f"TI Battle Pass Voice Line #{message_id}"
1140
+ if 401000 <= message_id < 402000:
1141
+ return f"TI Talent/Team Voice Line #{message_id}"
1142
+ return f"Voice Line #{message_id}"
1143
+
1144
+
1145
+ class GameActivity(int, Enum):
1146
+ """Dota 2 unit animation activity codes.
1147
+
1148
+ These are used in CDOTAUserMsg_TE_UnitAnimation messages to identify
1149
+ what animation a unit is playing. Useful for detecting taunts.
1150
+
1151
+ Usage:
1152
+ if animation_data['activity'] == GameActivity.TAUNT:
1153
+ print("Unit is taunting!")
1154
+
1155
+ Source: https://docs.moddota.com/lua_server_enums/
1156
+ """
1157
+ # Basic states
1158
+ IDLE = 1500
1159
+ IDLE_RARE = 1501
1160
+ RUN = 1502
1161
+ ATTACK = 1503
1162
+ ATTACK2 = 1504
1163
+ ATTACK_EVENT = 1505
1164
+ DIE = 1506
1165
+ FLINCH = 1507
1166
+ FLAIL = 1508
1167
+ DISABLED = 1509
1168
+ # Ability casting
1169
+ CAST_ABILITY_1 = 1510
1170
+ CAST_ABILITY_2 = 1511
1171
+ CAST_ABILITY_3 = 1512
1172
+ CAST_ABILITY_4 = 1513
1173
+ CAST_ABILITY_5 = 1514
1174
+ CAST_ABILITY_6 = 1515
1175
+ # Override abilities
1176
+ OVERRIDE_ABILITY_1 = 1516
1177
+ OVERRIDE_ABILITY_2 = 1517
1178
+ OVERRIDE_ABILITY_3 = 1518
1179
+ OVERRIDE_ABILITY_4 = 1519
1180
+ # Channeling
1181
+ CHANNEL_ABILITY_1 = 1520
1182
+ CHANNEL_ABILITY_2 = 1521
1183
+ CHANNEL_ABILITY_3 = 1522
1184
+ CHANNEL_ABILITY_4 = 1523
1185
+ CHANNEL_ABILITY_5 = 1524
1186
+ CHANNEL_ABILITY_6 = 1525
1187
+ CHANNEL_END_ABILITY_1 = 1526
1188
+ CHANNEL_END_ABILITY_2 = 1527
1189
+ CHANNEL_END_ABILITY_3 = 1528
1190
+ CHANNEL_END_ABILITY_4 = 1529
1191
+ CHANNEL_END_ABILITY_5 = 1530
1192
+ # Victory/Defeat
1193
+ CONSTANT_LAYER = 1531
1194
+ CAPTURE = 1532
1195
+ SPAWN = 1533
1196
+ KILLTAUNT = 1535
1197
+ TAUNT = 1536
1198
+ # Generic abilities
1199
+ CAST_ABILITY_ROT = 1537
1200
+ CAST_ABILITY_2_ES_ROLL_START = 1538
1201
+ CAST_ABILITY_2_ES_ROLL = 1539
1202
+ CAST_ABILITY_2_ES_ROLL_END = 1540
1203
+ RUN_ANIM = 1541
1204
+ CAST_ABILITY_4_END = 1543
1205
+ LOADOUT = 1559
1206
+ FORCESTAFF_END = 1560
1207
+ LOADOUT_RARE = 1561
1208
+ # Teleport
1209
+ TELEPORT = 1563
1210
+ TELEPORT_END = 1564
1211
+ # Special taunts
1212
+ TAUNT_SNIPER = 1641
1213
+ TAUNT_SPECIAL = 1752
1214
+ CUSTOM_TOWER_TAUNT = 1756
1215
+
1216
+ @property
1217
+ def display_name(self) -> str:
1218
+ """Human-readable activity name."""
1219
+ return self.name.replace("_", " ").title()
1220
+
1221
+ @property
1222
+ def is_taunt(self) -> bool:
1223
+ """True if this activity is a taunt animation."""
1224
+ return self in (
1225
+ GameActivity.TAUNT, GameActivity.KILLTAUNT,
1226
+ GameActivity.TAUNT_SNIPER, GameActivity.TAUNT_SPECIAL,
1227
+ GameActivity.CUSTOM_TOWER_TAUNT
1228
+ )
1229
+
1230
+ @property
1231
+ def is_attack(self) -> bool:
1232
+ """True if this activity is an attack animation."""
1233
+ return self in (GameActivity.ATTACK, GameActivity.ATTACK2, GameActivity.ATTACK_EVENT)
1234
+
1235
+ @property
1236
+ def is_ability_cast(self) -> bool:
1237
+ """True if this activity is an ability cast."""
1238
+ return 1510 <= self.value <= 1519
1239
+
1240
+ @property
1241
+ def is_channeling(self) -> bool:
1242
+ """True if this activity is a channeling animation."""
1243
+ return 1520 <= self.value <= 1530
1244
+
1245
+ @classmethod
1246
+ def from_value(cls, value: int) -> Optional["GameActivity"]:
1247
+ """Get GameActivity from integer value."""
1248
+ for activity in cls:
1249
+ if activity.value == value:
1250
+ return activity
1251
+ return None
1252
+
1253
+ @classmethod
1254
+ def get_taunt_activities(cls) -> List["GameActivity"]:
1255
+ """Get all taunt-related activities."""
1256
+ return [a for a in cls if a.is_taunt]
1257
+
1258
+
1259
+ class HeaderInfo(BaseModel):
1260
+ """Pydantic model for demo file header information."""
1261
+ map_name: str
1262
+ server_name: str
1263
+ client_name: str
1264
+ game_directory: str
1265
+ network_protocol: int
1266
+ demo_file_stamp: str
1267
+ build_num: int
1268
+ game: str
1269
+ server_start_tick: int
1270
+ success: bool
1271
+ error: Optional[str] = None
1272
+
1273
+
1274
+ class DraftEvent(BaseModel):
1275
+ """A single pick or ban event during the draft phase.
1276
+
1277
+ Maps to Manta's CGameInfo.CDotaGameInfo.CHeroSelectEvent protobuf.
1278
+ """
1279
+ is_pick: bool # True for pick, False for ban
1280
+ team: int # 2=Radiant, 3=Dire
1281
+ hero_id: int
1282
+
1283
+
1284
+ class PlayerInfo(BaseModel):
1285
+ """Player information from match metadata.
1286
+
1287
+ Maps to Manta's CGameInfo.CDotaGameInfo.CPlayerInfo protobuf.
1288
+ """
1289
+ model_config = {"populate_by_name": True}
1290
+
1291
+ hero_name: str = ""
1292
+ player_name: str = ""
1293
+ is_fake_client: bool = False
1294
+ steam_id: int = Field(default=0, alias="steamid")
1295
+ team: int = Field(default=0, alias="game_team") # 2=Radiant, 3=Dire
1296
+
1297
+
1298
+ class GameInfo(BaseModel):
1299
+ """Complete game information extracted from replay.
1300
+
1301
+ Contains match metadata, draft picks/bans, player info, and team data.
1302
+ For pro matches, includes team IDs, team tags, and league ID.
1303
+ For pub matches, team fields will be 0/empty.
1304
+
1305
+ Maps to Manta's CGameInfo.CDotaGameInfo protobuf.
1306
+ """
1307
+ model_config = {"populate_by_name": True}
1308
+
1309
+ match_id: int
1310
+ game_mode: int
1311
+ game_winner: int # 2=Radiant, 3=Dire
1312
+ league_id: int = 0
1313
+ end_time: int = 0
1314
+
1315
+ # Team info (pro matches only - 0/empty for pubs)
1316
+ radiant_team_id: int = 0
1317
+ dire_team_id: int = 0
1318
+ radiant_team_tag: str = ""
1319
+ dire_team_tag: str = ""
1320
+
1321
+ # Players (Go returns as "player_info")
1322
+ players: List[PlayerInfo] = Field(default=[], alias="player_info")
1323
+
1324
+ # Draft
1325
+ picks_bans: List[DraftEvent] = []
1326
+
1327
+ # Playback info
1328
+ playback_time: float = 0.0
1329
+ playback_ticks: int = 0
1330
+ playback_frames: int = 0
1331
+
1332
+ success: bool
1333
+ error: Optional[str] = None
1334
+
1335
+ def is_pro_match(self) -> bool:
1336
+ """Check if this is a pro/league match."""
1337
+ return self.league_id > 0 or self.radiant_team_id > 0 or self.dire_team_id > 0
1338
+
1339
+
1340
+ # Universal Message Event for ALL Manta callbacks
1341
+ class MessageEvent(BaseModel):
1342
+ """Universal message event that can capture ANY Manta message type."""
1343
+ type: str # Message type name (e.g., "CDemoFileHeader", "CDOTAUserMsg_ChatEvent")
1344
+ tick: int # Tick when message occurred
1345
+ net_tick: int # Net tick when message occurred
1346
+ data: Any # Raw message data (varies by message type)
1347
+ timestamp: Optional[int] = None # Unix timestamp (if available)
1348
+
1349
+
1350
+ class UniversalParseResult(BaseModel):
1351
+ """Result from universal parsing - captures ALL message types."""
1352
+ messages: List[MessageEvent] = []
1353
+ success: bool = True
1354
+ error: Optional[str] = None
1355
+ count: int = 0
1356
+
1357
+
1358
+ class TeamState(BaseModel):
1359
+ """Team state at a specific tick."""
1360
+ team_id: int
1361
+ score: int = 0
1362
+ tower_kills: int = 0
1363
+
1364
+
1365
+ class EntitySnapshot(BaseModel):
1366
+ """Entity state snapshot at a specific tick.
1367
+
1368
+ Contains complete hero state including economy, abilities, talents, combat stats,
1369
+ and attributes. All hero data is consolidated in the heroes field.
1370
+ """
1371
+ tick: int
1372
+ game_time: float
1373
+ heroes: List["HeroSnapshot"] = []
1374
+ teams: List[TeamState] = []
1375
+ raw_entities: Optional[Dict[str, Any]] = None
1376
+
1377
+
1378
+ class EntityParseConfig(BaseModel):
1379
+ """Configuration for entity parsing."""
1380
+ interval_ticks: int = 1800 # ~1 minute at 30 ticks/sec
1381
+ max_snapshots: int = 0 # 0 = unlimited
1382
+ target_ticks: List[int] = [] # Specific ticks to capture (overrides interval if set)
1383
+ target_heroes: List[str] = [] # Filter by hero name (npc_dota_hero_* format)
1384
+ entity_classes: List[str] = [] # Empty = default set
1385
+ include_raw: bool = False
1386
+
1387
+
1388
+ class EntityParseResult(BaseModel):
1389
+ """Result from entity state parsing."""
1390
+ snapshots: List[EntitySnapshot] = []
1391
+ success: bool = True
1392
+ error: Optional[str] = None
1393
+ total_ticks: int = 0
1394
+ snapshot_count: int = 0
1395
+
1396
+
1397
+ # ============================================================================
1398
+ # GAME EVENTS MODELS
1399
+ # ============================================================================
1400
+
1401
+ class GameEventData(BaseModel):
1402
+ """Parsed game event with typed fields."""
1403
+ name: str
1404
+ tick: int
1405
+ net_tick: int
1406
+ fields: Dict[str, Any] = {}
1407
+
1408
+
1409
+ class GameEventsConfig(BaseModel):
1410
+ """Configuration for game event parsing."""
1411
+ event_filter: str = "" # Filter by event name (substring)
1412
+ event_names: List[str] = [] # Specific events to capture
1413
+ max_events: int = 0 # Max events (0 = unlimited)
1414
+ capture_types: bool = True # Capture event type definitions
1415
+
1416
+
1417
+ class GameEventsResult(BaseModel):
1418
+ """Result from game events parsing."""
1419
+ events: List[GameEventData] = []
1420
+ event_types: List[str] = []
1421
+ success: bool = True
1422
+ error: Optional[str] = None
1423
+ total_events: int = 0
1424
+
1425
+
1426
+ # ============================================================================
1427
+ # MODIFIER/BUFF MODELS
1428
+ # ============================================================================
1429
+
1430
+ class ModifierEntry(BaseModel):
1431
+ """Buff/debuff modifier entry."""
1432
+ tick: int
1433
+ net_tick: int
1434
+ parent: int # Entity handle of unit with modifier
1435
+ caster: int # Entity handle of caster
1436
+ ability: int # Ability that created modifier
1437
+ modifier_class: int # Modifier class ID
1438
+ serial_num: int # Serial number
1439
+ index: int # Modifier index
1440
+ creation_time: float # When created
1441
+ duration: float # Duration (-1 = permanent)
1442
+ stack_count: int # Number of stacks
1443
+ is_aura: bool # Is an aura
1444
+ is_debuff: bool # Is a debuff
1445
+
1446
+
1447
+ class ModifiersConfig(BaseModel):
1448
+ """Configuration for modifier parsing."""
1449
+ max_modifiers: int = 0 # Max modifiers (0 = unlimited)
1450
+ debuffs_only: bool = False
1451
+ auras_only: bool = False
1452
+
1453
+
1454
+ class ModifiersResult(BaseModel):
1455
+ """Result from modifier parsing."""
1456
+ modifiers: List[ModifierEntry] = []
1457
+ success: bool = True
1458
+ error: Optional[str] = None
1459
+ total_modifiers: int = 0
1460
+
1461
+
1462
+ # ============================================================================
1463
+ # ENTITY QUERY MODELS
1464
+ # ============================================================================
1465
+
1466
+ class EntityData(BaseModel):
1467
+ """Full entity state data."""
1468
+ index: int
1469
+ serial: int
1470
+ class_name: str
1471
+ properties: Dict[str, Any] = {}
1472
+
1473
+
1474
+ class EntitiesConfig(BaseModel):
1475
+ """Configuration for entity querying."""
1476
+ class_filter: str = "" # Filter by class name (substring)
1477
+ class_names: List[str] = [] # Specific classes to capture
1478
+ property_filter: List[str] = [] # Only include these properties
1479
+ at_tick: int = 0 # Capture at tick (0 = end)
1480
+ max_entities: int = 0 # Max entities (0 = unlimited)
1481
+
1482
+
1483
+ class EntitiesResult(BaseModel):
1484
+ """Result from entity querying."""
1485
+ entities: List[EntityData] = []
1486
+ success: bool = True
1487
+ error: Optional[str] = None
1488
+ total_entities: int = 0
1489
+ tick: int = 0
1490
+ net_tick: int = 0
1491
+
1492
+
1493
+ # ============================================================================
1494
+ # STRING TABLE MODELS
1495
+ # ============================================================================
1496
+
1497
+ class StringTableData(BaseModel):
1498
+ """String table entry."""
1499
+ table_name: str
1500
+ index: int
1501
+ key: str
1502
+ value: Optional[str] = None
1503
+
1504
+
1505
+ class StringTablesConfig(BaseModel):
1506
+ """Configuration for string table extraction."""
1507
+ table_names: List[str] = [] # Tables to extract (empty = all)
1508
+ include_values: bool = False # Include value data
1509
+ max_entries: int = 100 # Max entries per table
1510
+
1511
+
1512
+ class StringTablesResult(BaseModel):
1513
+ """Result from string table extraction."""
1514
+ tables: Dict[str, List[StringTableData]] = {}
1515
+ table_names: List[str] = []
1516
+ success: bool = True
1517
+ error: Optional[str] = None
1518
+ total_entries: int = 0
1519
+
1520
+
1521
+ # ============================================================================
1522
+ # COMBAT LOG MODELS
1523
+ # ============================================================================
1524
+
1525
+ class CombatLogEntry(BaseModel):
1526
+ """Structured combat log entry with ALL available fields for fight reconstruction."""
1527
+ tick: int
1528
+ net_tick: int
1529
+ type: int
1530
+ type_name: str
1531
+ target_name: str = ""
1532
+ target_source_name: str = ""
1533
+ attacker_name: str = ""
1534
+ damage_source_name: str = ""
1535
+ inflictor_name: str = ""
1536
+ is_attacker_illusion: bool = False
1537
+ is_attacker_hero: bool = False
1538
+ is_target_illusion: bool = False
1539
+ is_target_hero: bool = False
1540
+ is_visible_radiant: bool = False
1541
+ is_visible_dire: bool = False
1542
+ value: int = 0
1543
+ value_name: str = ""
1544
+ health: int = 0
1545
+ timestamp: float = 0.0
1546
+ timestamp_raw: float = 0.0
1547
+ game_time: float = 0.0
1548
+ stun_duration: float = 0.0
1549
+ slow_duration: float = 0.0
1550
+ is_ability_toggle_on: bool = False
1551
+ is_ability_toggle_off: bool = False
1552
+ ability_level: int = 0
1553
+ xp: int = 0
1554
+ gold: int = 0
1555
+ last_hits: int = 0
1556
+ attacker_team: int = 0
1557
+ target_team: int = 0
1558
+ # Location data
1559
+ location_x: float = 0.0
1560
+ location_y: float = 0.0
1561
+ # Assist tracking
1562
+ assist_player0: int = 0
1563
+ assist_player1: int = 0
1564
+ assist_player2: int = 0
1565
+ assist_player3: int = 0
1566
+ assist_players: List[int] = []
1567
+ # Damage classification
1568
+ damage_type: int = 0
1569
+ damage_category: int = 0
1570
+ # Additional combat info
1571
+ is_target_building: bool = False
1572
+ is_ultimate_ability: bool = False
1573
+ is_heal_save: bool = False
1574
+ target_is_self: bool = False
1575
+ modifier_duration: float = 0.0
1576
+ stack_count: int = 0
1577
+ hidden_modifier: bool = False
1578
+ invisibility_modifier: bool = False
1579
+ # Hero levels
1580
+ attacker_hero_level: int = 0
1581
+ target_hero_level: int = 0
1582
+ # Economy stats
1583
+ xpm: int = 0
1584
+ gpm: int = 0
1585
+ event_location: int = 0
1586
+ networth: int = 0
1587
+ # Ward/rune/camp info
1588
+ obs_wards_placed: int = 0
1589
+ neutral_camp_type: int = 0
1590
+ neutral_camp_team: int = 0
1591
+ rune_type: int = 0
1592
+ # Building info
1593
+ building_type: int = 0
1594
+ # Modifier details
1595
+ modifier_elapsed_duration: float = 0.0
1596
+ silence_modifier: bool = False
1597
+ heal_from_lifesteal: bool = False
1598
+ modifier_purged: bool = False
1599
+ modifier_purge_ability: int = 0
1600
+ modifier_purge_ability_name: str = ""
1601
+ modifier_purge_npc: int = 0
1602
+ modifier_purge_npc_name: str = ""
1603
+ root_modifier: bool = False
1604
+ aura_modifier: bool = False
1605
+ armor_debuff_modifier: bool = False
1606
+ no_physical_damage_modifier: bool = False
1607
+ modifier_ability: int = 0
1608
+ modifier_ability_name: str = ""
1609
+ modifier_hidden: bool = False
1610
+ motion_controller_modifier: bool = False
1611
+ # Kill/death info
1612
+ spell_evaded: bool = False
1613
+ long_range_kill: bool = False
1614
+ total_unit_death_count: int = 0
1615
+ will_reincarnate: bool = False
1616
+ # Ability info
1617
+ inflictor_is_stolen_ability: bool = False
1618
+ spell_generated_attack: bool = False
1619
+ uses_charges: bool = False
1620
+ # Game state
1621
+ at_night_time: bool = False
1622
+ attacker_has_scepter: bool = False
1623
+ regenerated_health: float = 0.0
1624
+ # Tracking/events
1625
+ kill_eater_event: int = 0
1626
+ unit_status_label: int = 0
1627
+ tracked_stat_id: int = 0
1628
+
1629
+
1630
+ class CombatLogConfig(BaseModel):
1631
+ """Configuration for combat log parsing."""
1632
+ types: List[int] = [] # Filter by type (empty = all)
1633
+ max_entries: int = 0 # Max entries (0 = unlimited)
1634
+ heroes_only: bool = False # Only hero-related
1635
+
1636
+
1637
+ class CombatLogResult(BaseModel):
1638
+ """Result from combat log parsing."""
1639
+ entries: List[CombatLogEntry] = []
1640
+ success: bool = True
1641
+ error: Optional[str] = None
1642
+ total_entries: int = 0
1643
+ game_start_time: float = 0.0
1644
+
1645
+
1646
+ # ============================================================================
1647
+ # PARSER INFO MODEL
1648
+ # ============================================================================
1649
+
1650
+ class ParserInfo(BaseModel):
1651
+ """Parser state information."""
1652
+ game_build: int = 0
1653
+ tick: int = 0
1654
+ net_tick: int = 0
1655
+ string_tables: List[str] = []
1656
+ entity_count: int = 0
1657
+ success: bool = True
1658
+ error: Optional[str] = None
1659
+
1660
+
1661
+ # ============================================================================
1662
+ # V2 PARSER TYPES AND CLASS - UNIFIED SINGLE-PASS API
1663
+ # ============================================================================
1664
+
1665
+
1666
+ class HeaderCollectorConfig(BaseModel):
1667
+ """Config for header collection."""
1668
+ enabled: bool = True
1669
+
1670
+
1671
+ class GameInfoCollectorConfig(BaseModel):
1672
+ """Config for game info collection."""
1673
+ enabled: bool = True
1674
+
1675
+
1676
+ class MessagesCollectorConfig(BaseModel):
1677
+ """Config for universal messages collection."""
1678
+ filter: str = ""
1679
+ max_messages: int = 0
1680
+
1681
+
1682
+ class ParserInfoCollectorConfig(BaseModel):
1683
+ """Config for parser info collection."""
1684
+ enabled: bool = True
1685
+
1686
+
1687
+ class ParseConfig(BaseModel):
1688
+ """Configuration for single-pass parsing with multiple collectors."""
1689
+ header: Optional[HeaderCollectorConfig] = None
1690
+ game_info: Optional[GameInfoCollectorConfig] = None
1691
+ combat_log: Optional[CombatLogConfig] = None
1692
+ entities: Optional[EntityParseConfig] = None
1693
+ game_events: Optional[GameEventsConfig] = None
1694
+ modifiers: Optional[ModifiersConfig] = None
1695
+ string_tables: Optional[StringTablesConfig] = None
1696
+ messages: Optional[MessagesCollectorConfig] = None
1697
+ parser_info: Optional[ParserInfoCollectorConfig] = None
1698
+
1699
+
1700
+ class MessagesResult(BaseModel):
1701
+ """Result from messages collector."""
1702
+ messages: List[MessageEvent] = []
1703
+ success: bool = True
1704
+ error: Optional[str] = None
1705
+ total_messages: int = 0
1706
+ filtered_count: int = 0
1707
+ callbacks_used: List[str] = []
1708
+
1709
+
1710
+ class ParseResult(BaseModel):
1711
+ """Result from single-pass parsing with all collected data."""
1712
+ success: bool = True
1713
+ error: Optional[str] = None
1714
+
1715
+ header: Optional[HeaderInfo] = None
1716
+ game_info: Optional[GameInfo] = None
1717
+ combat_log: Optional[CombatLogResult] = None
1718
+ entities: Optional[EntityParseResult] = None
1719
+ game_events: Optional[GameEventsResult] = None
1720
+ modifiers: Optional[ModifiersResult] = None
1721
+ string_tables: Optional[StringTablesResult] = None
1722
+ messages: Optional[MessagesResult] = None
1723
+ parser_info: Optional[ParserInfo] = None
1724
+
1725
+
1726
+ class StreamConfig(BaseModel):
1727
+ """Configuration for streaming parse."""
1728
+ combat_log: bool = False
1729
+ messages: bool = False
1730
+ game_events: bool = False
1731
+ max_events: int = 1000
1732
+
1733
+
1734
+ class StreamEvent(BaseModel):
1735
+ """A single event from streaming parse."""
1736
+ kind: str = ""
1737
+ tick: int = 0
1738
+ type: str = ""
1739
+ data: Dict[str, Any] = {}
1740
+
1741
+
1742
+ class StreamResult(BaseModel):
1743
+ """Result from streaming parse open."""
1744
+ success: bool = True
1745
+ handle_id: int = 0
1746
+ error: Optional[str] = None
1747
+
1748
+
1749
+ class Keyframe(BaseModel):
1750
+ """A seekable keyframe in the demo."""
1751
+ tick: int = 0
1752
+ offset: int = 0
1753
+ game_time: float = 0.0
1754
+
1755
+
1756
+ class DemoIndex(BaseModel):
1757
+ """Index of keyframes for seeking."""
1758
+ keyframes: List[Keyframe] = []
1759
+ total_ticks: int = 0
1760
+ game_started: int = 0
1761
+ success: bool = True
1762
+ error: Optional[str] = None
1763
+
1764
+
1765
+ class AbilitySnapshot(BaseModel):
1766
+ """State of a single ability at a specific tick.
1767
+
1768
+ Abilities are tracked from hero entity's m_vecAbilities array. Each ability
1769
+ has a slot index (0-5 for regular abilities) and various state properties.
1770
+ """
1771
+ slot: int = 0
1772
+ name: str = ""
1773
+ level: int = 0
1774
+ cooldown: float = 0.0
1775
+ max_cooldown: float = 0.0
1776
+ mana_cost: int = 0
1777
+ charges: int = 0
1778
+ is_ultimate: bool = False
1779
+
1780
+ @property
1781
+ def short_name(self) -> str:
1782
+ """Return ability name without CDOTA_Ability_ prefix."""
1783
+ return self.name.replace("CDOTA_Ability_", "")
1784
+
1785
+ @property
1786
+ def is_maxed(self) -> bool:
1787
+ """True if ability is at max level (typically 4 for regular, 3 for ultimate)."""
1788
+ if self.is_ultimate:
1789
+ return self.level >= 3
1790
+ return self.level >= 4
1791
+
1792
+ @property
1793
+ def is_on_cooldown(self) -> bool:
1794
+ """True if ability is currently on cooldown."""
1795
+ return self.cooldown > 0
1796
+
1797
+
1798
+ class TalentChoice(BaseModel):
1799
+ """A talent choice made by a hero.
1800
+
1801
+ Talents are selected at levels 10, 15, 20, and 25. Each tier offers two
1802
+ choices (left and right). This model captures which choice was made.
1803
+ """
1804
+ tier: int = 0
1805
+ slot: int = 0
1806
+ is_left: bool = True
1807
+ name: str = ""
1808
+
1809
+ @property
1810
+ def side(self) -> str:
1811
+ """Return 'left' or 'right' based on talent choice."""
1812
+ return "left" if self.is_left else "right"
1813
+
1814
+
1815
+ class HeroSnapshot(BaseModel):
1816
+ """Complete hero state at a specific tick.
1817
+
1818
+ Consolidates all hero data: identity, position, vitals, economy, combat stats,
1819
+ attributes, abilities, and talents. This is the primary model for hero state
1820
+ in entity snapshots.
1821
+ """
1822
+ # Identity
1823
+ hero_name: str = ""
1824
+ hero_id: int = 0
1825
+ player_id: int = 0
1826
+ team: int = 0
1827
+ index: int = 0
1828
+
1829
+ # Position
1830
+ x: float = 0.0
1831
+ y: float = 0.0
1832
+ z: float = 0.0
1833
+
1834
+ # Vital stats
1835
+ health: int = 0
1836
+ max_health: int = 0
1837
+ mana: float = 0.0
1838
+ max_mana: float = 0.0
1839
+ level: int = 0
1840
+ is_alive: bool = True
1841
+
1842
+ # Economy
1843
+ gold: int = 0
1844
+ net_worth: int = 0
1845
+ last_hits: int = 0
1846
+ denies: int = 0
1847
+ xp: int = 0
1848
+
1849
+ # KDA
1850
+ kills: int = 0
1851
+ deaths: int = 0
1852
+ assists: int = 0
1853
+
1854
+ # Combat stats
1855
+ armor: float = 0.0
1856
+ magic_resistance: float = 0.0
1857
+ damage_min: int = 0
1858
+ damage_max: int = 0
1859
+ attack_range: int = 0
1860
+
1861
+ # Attributes
1862
+ strength: float = 0.0
1863
+ agility: float = 0.0
1864
+ intellect: float = 0.0
1865
+
1866
+ # Abilities and talents
1867
+ abilities: List[AbilitySnapshot] = []
1868
+ talents: List[TalentChoice] = []
1869
+ ability_points: int = 0
1870
+
1871
+ # Clone/illusion flags
1872
+ is_illusion: bool = False
1873
+ is_clone: bool = False
1874
+
1875
+ @property
1876
+ def kda(self) -> str:
1877
+ """Return KDA as a formatted string (e.g., '5/2/10')."""
1878
+ return f"{self.kills}/{self.deaths}/{self.assists}"
1879
+
1880
+ @property
1881
+ def has_ultimate(self) -> bool:
1882
+ """True if hero has learned their ultimate ability."""
1883
+ for ability in self.abilities:
1884
+ if ability.is_ultimate and ability.level > 0:
1885
+ return True
1886
+ return False
1887
+
1888
+ @property
1889
+ def talents_chosen(self) -> int:
1890
+ """Number of talents selected (0-4)."""
1891
+ return len(self.talents)
1892
+
1893
+ def get_ability(self, name: str) -> Optional[AbilitySnapshot]:
1894
+ """Get ability by name (partial match supported)."""
1895
+ name_lower = name.lower()
1896
+ for ability in self.abilities:
1897
+ if name_lower in ability.name.lower():
1898
+ return ability
1899
+ return None
1900
+
1901
+ def get_talent_at_tier(self, tier: int) -> Optional[TalentChoice]:
1902
+ """Get the talent chosen at a specific tier (10, 15, 20, or 25)."""
1903
+ for talent in self.talents:
1904
+ if talent.tier == tier:
1905
+ return talent
1906
+ return None
1907
+
1908
+
1909
+ class EntityStateSnapshot(BaseModel):
1910
+ """Entity state snapshot at a specific tick."""
1911
+ tick: int = 0
1912
+ game_time: float = 0.0
1913
+ heroes: List[HeroSnapshot] = []
1914
+ success: bool = True
1915
+ error: Optional[str] = None
1916
+
1917
+
1918
+ class RangeParseConfig(BaseModel):
1919
+ """Configuration for range parsing."""
1920
+ start_tick: int = 0
1921
+ end_tick: int = 0
1922
+ combat_log: bool = False
1923
+ messages: bool = False
1924
+ game_events: bool = False
1925
+
1926
+
1927
+ class RangeParseResult(BaseModel):
1928
+ """Result from parsing a specific tick range."""
1929
+ start_tick: int = 0
1930
+ end_tick: int = 0
1931
+ actual_start: int = 0
1932
+ actual_end: int = 0
1933
+ combat_log: List[Dict[str, Any]] = []
1934
+ messages: List[Dict[str, Any]] = []
1935
+ success: bool = True
1936
+ error: Optional[str] = None
1937
+
1938
+
1939
+ class KeyframeResult(BaseModel):
1940
+ """Result from finding a keyframe."""
1941
+ success: bool = True
1942
+ keyframe: Optional[Keyframe] = None
1943
+ exact: bool = False
1944
+ error: Optional[str] = None
1945
+
1946
+
1947
+ class Parser:
1948
+ """V2 Parser with unified single-pass parsing.
1949
+
1950
+ This is the recommended API that parses the file once and collects
1951
+ all requested data in a single pass.
1952
+
1953
+ Usage:
1954
+ parser = Parser("match.dem")
1955
+ result = parser.parse(
1956
+ header=True,
1957
+ game_info=True,
1958
+ combat_log={"types": [4], "heroes_only": True},
1959
+ entities={"interval_ticks": 900},
1960
+ )
1961
+
1962
+ print(result.header.map_name)
1963
+ print(result.game_info.match_id)
1964
+ print(len(result.combat_log.entries))
1965
+ """
1966
+
1967
+ _BZ2_MAGIC = b'BZh'
1968
+
1969
+ def __init__(self, demo_path: str, library_path: Optional[str] = None):
1970
+ """Initialize parser for a specific demo file."""
1971
+ self._demo_path = demo_path
1972
+ self._decompressed_cache: Dict[str, str] = {}
1973
+
1974
+ if library_path is None:
1975
+ library_path = Path(__file__).parent / "libmanta_wrapper.so"
1976
+
1977
+ if not os.path.exists(library_path):
1978
+ raise FileNotFoundError(f"Shared library not found: {library_path}")
1979
+
1980
+ self._lib = ctypes.CDLL(str(library_path))
1981
+ self._setup_function_signatures()
1982
+
1983
+ def _setup_function_signatures(self):
1984
+ """Configure ctypes function signatures."""
1985
+ self._lib.Parse.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
1986
+ self._lib.Parse.restype = ctypes.c_char_p
1987
+
1988
+ self._lib.FreeString.argtypes = [ctypes.c_char_p]
1989
+ self._lib.FreeString.restype = None
1990
+
1991
+ self._lib.StreamOpen.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
1992
+ self._lib.StreamOpen.restype = ctypes.c_char_p
1993
+
1994
+ self._lib.StreamNext.argtypes = [ctypes.c_longlong]
1995
+ self._lib.StreamNext.restype = ctypes.c_char_p
1996
+
1997
+ self._lib.StreamClose.argtypes = [ctypes.c_longlong]
1998
+ self._lib.StreamClose.restype = ctypes.c_char_p
1999
+
2000
+ self._lib.BuildIndex.argtypes = [ctypes.c_char_p, ctypes.c_int]
2001
+ self._lib.BuildIndex.restype = ctypes.c_char_p
2002
+
2003
+ self._lib.GetSnapshot.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
2004
+ self._lib.GetSnapshot.restype = ctypes.c_char_p
2005
+
2006
+ self._lib.ParseRange.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
2007
+ self._lib.ParseRange.restype = ctypes.c_char_p
2008
+
2009
+ self._lib.FindKeyframe.argtypes = [ctypes.c_char_p, ctypes.c_int]
2010
+ self._lib.FindKeyframe.restype = ctypes.c_char_p
2011
+
2012
+ def _prepare_demo_file(self, demo_file_path: str) -> str:
2013
+ """Prepare demo file, decompressing if needed."""
2014
+ if demo_file_path in self._decompressed_cache:
2015
+ cached_path = self._decompressed_cache[demo_file_path]
2016
+ if os.path.exists(cached_path):
2017
+ return cached_path
2018
+
2019
+ if os.path.isdir(demo_file_path):
2020
+ raise ValueError(f"Parsing failed: '{demo_file_path}' is a directory, not a file")
2021
+
2022
+ with open(demo_file_path, 'rb') as f:
2023
+ magic = f.read(3)
2024
+
2025
+ if magic == self._BZ2_MAGIC:
2026
+ temp_fd, temp_path = tempfile.mkstemp(suffix='.dem')
2027
+ try:
2028
+ with bz2.open(demo_file_path, 'rb') as f_in:
2029
+ with os.fdopen(temp_fd, 'wb') as f_out:
2030
+ while True:
2031
+ chunk = f_in.read(1024 * 1024)
2032
+ if not chunk:
2033
+ break
2034
+ f_out.write(chunk)
2035
+
2036
+ self._decompressed_cache[demo_file_path] = temp_path
2037
+ return temp_path
2038
+ except Exception as e:
2039
+ if os.path.exists(temp_path):
2040
+ os.unlink(temp_path)
2041
+ raise ValueError(f"Failed to decompress bz2 file: {e}")
2042
+
2043
+ return demo_file_path
2044
+
2045
+ def parse(
2046
+ self,
2047
+ header: bool = False,
2048
+ game_info: bool = False,
2049
+ combat_log: Optional[Dict[str, Any]] = None,
2050
+ entities: Optional[Dict[str, Any]] = None,
2051
+ game_events: Optional[Dict[str, Any]] = None,
2052
+ modifiers: Optional[Dict[str, Any]] = None,
2053
+ string_tables: Optional[Dict[str, Any]] = None,
2054
+ messages: Optional[Dict[str, Any]] = None,
2055
+ parser_info: bool = False,
2056
+ ) -> ParseResult:
2057
+ """Parse the demo file with specified collectors.
2058
+
2059
+ This method parses the file ONCE, collecting all requested data
2060
+ in a single pass. Much more efficient than multiple parse_* calls.
2061
+
2062
+ Args:
2063
+ header: Collect header info
2064
+ game_info: Collect game info (match, players, draft)
2065
+ combat_log: Combat log config dict (types, max_entries, heroes_only)
2066
+ entities: Entity snapshot config (interval_ticks, max_snapshots, etc.)
2067
+ game_events: Game events config (event_filter, max_events, etc.)
2068
+ modifiers: Modifiers config (max_modifiers, auras_only, etc.)
2069
+ string_tables: String tables config (table_names, max_entries, etc.)
2070
+ messages: Universal messages config (filter, max_messages)
2071
+ parser_info: Collect parser state info
2072
+
2073
+ Returns:
2074
+ ParseResult with all requested data
2075
+ """
2076
+ if not os.path.exists(self._demo_path):
2077
+ raise FileNotFoundError(f"Demo file not found: {self._demo_path}")
2078
+
2079
+ actual_path = self._prepare_demo_file(self._demo_path)
2080
+
2081
+ config = ParseConfig()
2082
+
2083
+ if header:
2084
+ config.header = HeaderCollectorConfig(enabled=True)
2085
+
2086
+ if game_info:
2087
+ config.game_info = GameInfoCollectorConfig(enabled=True)
2088
+
2089
+ if combat_log is not None:
2090
+ config.combat_log = CombatLogConfig(**combat_log)
2091
+
2092
+ if entities is not None:
2093
+ config.entities = EntityParseConfig(**entities)
2094
+
2095
+ if game_events is not None:
2096
+ config.game_events = GameEventsConfig(**game_events)
2097
+
2098
+ if modifiers is not None:
2099
+ config.modifiers = ModifiersConfig(**modifiers)
2100
+
2101
+ if string_tables is not None:
2102
+ config.string_tables = StringTablesConfig(**string_tables)
2103
+
2104
+ if messages is not None:
2105
+ config.messages = MessagesCollectorConfig(**messages)
2106
+
2107
+ if parser_info:
2108
+ config.parser_info = ParserInfoCollectorConfig(enabled=True)
2109
+
2110
+ path_bytes = actual_path.encode('utf-8')
2111
+ config_json = config.model_dump_json(exclude_none=True).encode('utf-8')
2112
+
2113
+ result_ptr = self._lib.Parse(path_bytes, config_json)
2114
+
2115
+ if not result_ptr:
2116
+ raise ValueError("Parse returned null pointer")
2117
+
2118
+ try:
2119
+ result_json = ctypes.string_at(result_ptr).decode('utf-8')
2120
+ result_dict = json.loads(result_json)
2121
+ result = ParseResult(**result_dict)
2122
+
2123
+ if not result.success:
2124
+ raise ValueError(f"Parsing failed: {result.error}")
2125
+
2126
+ return result
2127
+ finally:
2128
+ pass
2129
+
2130
+ def stream(
2131
+ self,
2132
+ combat_log: bool = False,
2133
+ messages: bool = False,
2134
+ game_events: bool = False,
2135
+ max_events: int = 1000,
2136
+ ) -> Iterator[StreamEvent]:
2137
+ """Stream events from the demo file."""
2138
+ if not os.path.exists(self._demo_path):
2139
+ raise FileNotFoundError(f"Demo file not found: {self._demo_path}")
2140
+
2141
+ actual_path = self._prepare_demo_file(self._demo_path)
2142
+
2143
+ config = StreamConfig(
2144
+ combat_log=combat_log,
2145
+ messages=messages,
2146
+ game_events=game_events,
2147
+ max_events=max_events,
2148
+ )
2149
+
2150
+ path_bytes = actual_path.encode('utf-8')
2151
+ config_json = config.model_dump_json().encode('utf-8')
2152
+
2153
+ open_result_ptr = self._lib.StreamOpen(path_bytes, config_json)
2154
+ if not open_result_ptr:
2155
+ raise ValueError("StreamOpen returned null pointer")
2156
+
2157
+ open_result_json = ctypes.string_at(open_result_ptr).decode('utf-8')
2158
+ open_result = json.loads(open_result_json)
2159
+
2160
+ if not open_result.get('success', False):
2161
+ raise ValueError(f"StreamOpen failed: {open_result.get('error', 'Unknown error')}")
2162
+
2163
+ handle_id = open_result['handle_id']
2164
+
2165
+ try:
2166
+ import time
2167
+ while True:
2168
+ next_result_ptr = self._lib.StreamNext(handle_id)
2169
+ if not next_result_ptr:
2170
+ break
2171
+
2172
+ next_result_json = ctypes.string_at(next_result_ptr).decode('utf-8')
2173
+ next_result = json.loads(next_result_json)
2174
+
2175
+ if not next_result.get('success', False):
2176
+ error = next_result.get('error', 'Unknown error')
2177
+ if error:
2178
+ raise ValueError(f"StreamNext failed: {error}")
2179
+ break
2180
+
2181
+ if next_result.get('done', False):
2182
+ break
2183
+
2184
+ if next_result.get('event'):
2185
+ yield StreamEvent(**next_result['event'])
2186
+ else:
2187
+ time.sleep(0.001)
2188
+
2189
+ finally:
2190
+ self._lib.StreamClose(handle_id)
2191
+
2192
+ def build_index(self, interval_ticks: int = 1800) -> DemoIndex:
2193
+ """Build an index of keyframes for seeking within the demo."""
2194
+ if not os.path.exists(self._demo_path):
2195
+ raise FileNotFoundError(f"Demo file not found: {self._demo_path}")
2196
+
2197
+ actual_path = self._prepare_demo_file(self._demo_path)
2198
+ path_bytes = actual_path.encode('utf-8')
2199
+
2200
+ result_ptr = self._lib.BuildIndex(path_bytes, interval_ticks)
2201
+
2202
+ if not result_ptr:
2203
+ raise ValueError("BuildIndex returned null pointer")
2204
+
2205
+ result_json = ctypes.string_at(result_ptr).decode('utf-8')
2206
+ result_dict = json.loads(result_json)
2207
+ result = DemoIndex(**result_dict)
2208
+
2209
+ if not result.success:
2210
+ raise ValueError(f"Index building failed: {result.error}")
2211
+
2212
+ return result
2213
+
2214
+ def snapshot(self, target_tick: int, include_illusions: bool = False) -> EntityStateSnapshot:
2215
+ """Get entity state snapshot at a specific tick."""
2216
+ if not os.path.exists(self._demo_path):
2217
+ raise FileNotFoundError(f"Demo file not found: {self._demo_path}")
2218
+
2219
+ actual_path = self._prepare_demo_file(self._demo_path)
2220
+ path_bytes = actual_path.encode('utf-8')
2221
+
2222
+ config = {"target_tick": target_tick, "include_illusions": include_illusions}
2223
+ config_json = json.dumps(config).encode('utf-8')
2224
+
2225
+ result_ptr = self._lib.GetSnapshot(path_bytes, config_json)
2226
+
2227
+ if not result_ptr:
2228
+ raise ValueError("GetSnapshot returned null pointer")
2229
+
2230
+ result_json = ctypes.string_at(result_ptr).decode('utf-8')
2231
+ result_dict = json.loads(result_json)
2232
+ result = EntityStateSnapshot(**result_dict)
2233
+
2234
+ if not result.success:
2235
+ raise ValueError(f"Snapshot failed: {result.error}")
2236
+
2237
+ return result
2238
+
2239
+ def parse_range(
2240
+ self,
2241
+ start_tick: int,
2242
+ end_tick: int,
2243
+ combat_log: bool = False,
2244
+ messages: bool = False,
2245
+ game_events: bool = False,
2246
+ ) -> RangeParseResult:
2247
+ """Parse events within a specific tick range."""
2248
+ if not os.path.exists(self._demo_path):
2249
+ raise FileNotFoundError(f"Demo file not found: {self._demo_path}")
2250
+
2251
+ actual_path = self._prepare_demo_file(self._demo_path)
2252
+
2253
+ config = RangeParseConfig(
2254
+ start_tick=start_tick,
2255
+ end_tick=end_tick,
2256
+ combat_log=combat_log,
2257
+ messages=messages,
2258
+ game_events=game_events,
2259
+ )
2260
+
2261
+ path_bytes = actual_path.encode('utf-8')
2262
+ config_json = config.model_dump_json().encode('utf-8')
2263
+
2264
+ result_ptr = self._lib.ParseRange(path_bytes, config_json)
2265
+
2266
+ if not result_ptr:
2267
+ raise ValueError("ParseRange returned null pointer")
2268
+
2269
+ result_json = ctypes.string_at(result_ptr).decode('utf-8')
2270
+ result_dict = json.loads(result_json)
2271
+ result = RangeParseResult(**result_dict)
2272
+
2273
+ if not result.success:
2274
+ raise ValueError(f"Range parsing failed: {result.error}")
2275
+
2276
+ return result
2277
+
2278
+ def find_keyframe(self, index: DemoIndex, target_tick: int) -> KeyframeResult:
2279
+ """Find the nearest keyframe at or before a target tick."""
2280
+ index_json = index.model_dump_json().encode('utf-8')
2281
+
2282
+ result_ptr = self._lib.FindKeyframe(index_json, target_tick)
2283
+
2284
+ if not result_ptr:
2285
+ raise ValueError("FindKeyframe returned null pointer")
2286
+
2287
+ result_json = ctypes.string_at(result_ptr).decode('utf-8')
2288
+ result_dict = json.loads(result_json)
2289
+ result = KeyframeResult(**result_dict)
2290
+
2291
+ if not result.success:
2292
+ raise ValueError(f"Keyframe search failed: {result.error}")
2293
+
2294
+ return result
2295
+
2296
+
2297
+
2298
+ def _run_cli(argv=None):
2299
+ """Run the CLI interface. Separated for testing."""
2300
+ import sys
2301
+
2302
+ if argv is None:
2303
+ argv = sys.argv
2304
+
2305
+ if len(argv) != 2:
2306
+ print("Usage: python manta_python.py <demo_file.dem>")
2307
+ sys.exit(1)
2308
+
2309
+ demo_file = argv[1]
2310
+
2311
+ try:
2312
+ parser = Parser(demo_file)
2313
+ result = parser.parse(header=True)
2314
+ header = result.header
2315
+ print(f"Success! Parsed header from: {demo_file}")
2316
+ print(f" Map: {header.map_name}")
2317
+ print(f" Server: {header.server_name}")
2318
+ print(f" Client: {header.client_name}")
2319
+ print(f" Game Directory: {header.game_directory}")
2320
+ print(f" Network Protocol: {header.network_protocol}")
2321
+ print(f" Demo File Stamp: {header.demo_file_stamp}")
2322
+ print(f" Build Num: {header.build_num}")
2323
+ print(f" Game: {header.game}")
2324
+ print(f" Server Start Tick: {header.server_start_tick}")
2325
+
2326
+ except Exception as e:
2327
+ print(f"Error: {e}")
2328
+ sys.exit(1)
2329
+
2330
+
2331
+ if __name__ == "__main__":
2332
+ _run_cli()