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.
- python_manta/__init__.py +159 -0
- python_manta/libmanta_wrapper.h +139 -0
- python_manta/libmanta_wrapper.so +0 -0
- python_manta/manta_python.py +2332 -0
- python_manta-1.4.5.3.dist-info/METADATA +1432 -0
- python_manta-1.4.5.3.dist-info/RECORD +8 -0
- python_manta-1.4.5.3.dist-info/WHEEL +5 -0
- python_manta-1.4.5.3.dist-info/top_level.txt +1 -0
|
@@ -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()
|