passives.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. #!/usr/bin/python3
  2. import logging
  3. from argparse import ArgumentParser, ArgumentError
  4. from datetime import datetime
  5. from os import fstat
  6. from re import compile
  7. TIME_FORMAT = "%Y/%m/%d %H:%M:%S"
  8. def extract_delta(logline: str) -> int:
  9. """
  10. Extracts a delta from a Client.txt log line.
  11. The 3rd column is a millisecond delta from some specific but unknowable time in the past.
  12. This is useful to group together /passives messages which, historically, have all had the
  13. same delta.
  14. """
  15. return int(logline.split(" ")[2])
  16. def extract_time(line: str):
  17. """
  18. Extracts a time from a Client.txt log line.
  19. """
  20. # ls = line.split(' ')[0:2]
  21. # strptime(f"{ls[0]} {ls[1]} {ls[2][-3:]}000", "%Y/%m/%d %H:%M:%S %f")
  22. return datetime.strptime(" ".join(line.split(" ")[0:2]), TIME_FORMAT)
  23. class PassivesList:
  24. """
  25. An object encapsulating all of the data output by one /passives command
  26. """
  27. MATCHLINE = compile(r": (\d+) Passive Skill Points from quests:$")
  28. QUESTLINE = compile(r": \((\d) from ([^\)]+)")
  29. def __init__(self, logline):
  30. m = self.MATCHLINE.search(logline)
  31. if not m:
  32. raise ValueError("Invalid init logline")
  33. self.start_time = extract_time(logline)
  34. self.delta = extract_delta(logline)
  35. self.uncompleted_quests = {
  36. "The Marooned Mariner": "A1: Ship Graveyard (Allflame) - Fairgraves)",
  37. "The Dweller of the Deep": "A1: Flood depths 🦀 - Tarkleigh",
  38. "The Way Forward": "A2: Western Forest (Thaumetic Emblem, Blackguards) - Bestel, A1",
  39. "Victario's Secrets": "A3: Sewer (busts) - Hargan",
  40. "Piety's Pets": "A3 - Grigor",
  41. "An Indomitable Spirit": " A4: Mines level 2 (Deshret spirit) - Tasuni",
  42. "In Service to Science": "A5: Control Blocks (Miasmeter) - Vilenta",
  43. "Kitava's Torments": "A5: Reliquary (3 torments) - Lani",
  44. "The Father of War": "A6: Karui Fortress (Tukohama) - Tarkleigh",
  45. "The Cloven One": "A6: Prisoner's Gate (Abberath) - Bestel",
  46. "The Puppet Mistress": "A6: The Wetlands via Riverways (Ryslatha) - Tarkleigh",
  47. "The Master of a Million Faces": "A7 - Eramir",
  48. "Queen of Despair": "A7: Dread Thicket via Northern Forest (Gruthkul) - Eramir",
  49. "Kishara's Star": "A7: Causeway (Kishara's Star) - Weylam",
  50. "Love is Dead": "A8: Quay (Ankh) - Clarissa",
  51. "Reflection of Terror": "A8: High Gardens via Bath House (YUGUUUUUUUUUL) - Sin",
  52. "The Gemling Legion": "A8: Grain Gate (Legionnaires) - Maramoa",
  53. "Queen of the Sands": "A9: Oasis (Shakari) - Irasha",
  54. "The Ruler of Highgate": "A9: Quarry (Garukhan) - Irasha or Tasuni",
  55. "Vilenta's Vengeance": "A10: Control Blocks via Ravaged Square (Vilenta) - Lani",
  56. "An End to Hunger": "Kitava: Oriath - Lani",
  57. }
  58. self.completed_quests = {}
  59. self.completed_count = int(m.group(1))
  60. self.parsed_count = 0
  61. self._parsed_line_count = 0
  62. @property
  63. def name(self) -> str:
  64. return self.start_time.strftime(TIME_FORMAT)
  65. @property
  66. def full(self) -> bool:
  67. return self.parsed_count == self.completed_count
  68. def try_append(self, logline) -> bool:
  69. m = self.QUESTLINE.search(logline)
  70. if m:
  71. passive_count = int(m.group(1))
  72. quest_name = m.group(2)
  73. logging.debug(f"Found quest line: {passive_count}, {quest_name}")
  74. if -250 < extract_delta(logline) - self.delta < 250:
  75. logging.debug(f"Delta matches >{self.delta}<")
  76. if "Bandit" in quest_name:
  77. self.parsed_count += passive_count
  78. logging.debug(
  79. f"{self.name} | {self.parsed_count}/{self.completed_count} | {self.full}"
  80. )
  81. return True
  82. else:
  83. try:
  84. # Move description over because why not
  85. self.completed_quests[quest_name] = self.uncompleted_quests[
  86. quest_name
  87. ]
  88. del self.uncompleted_quests[quest_name]
  89. self.parsed_count += passive_count
  90. logging.debug(
  91. f"{self.name} | {self.parsed_count}/{self.completed_count} | {self.full}"
  92. )
  93. return True
  94. except KeyError as e:
  95. raise KeyError(
  96. f"Quest '{quest_name}' was not defined or already parsed."
  97. ) from e
  98. else:
  99. # TODO: Make this a parsing exception?
  100. # Delta mismatch
  101. pass
  102. return False
  103. class PassivesListContainer:
  104. FASTMATCH = "quests"
  105. def __init__(self):
  106. self.container = []
  107. self.parsed_count = 0
  108. @property
  109. def _is_open(self):
  110. return self.container and not self._last.full
  111. @property
  112. def _last(self):
  113. if self.container:
  114. return self.container[-1]
  115. def parse(self, logline):
  116. if self._is_open:
  117. # logging.debug(f"OPEN: {logline}")
  118. self._last.try_append(logline)
  119. if self.FASTMATCH in logline:
  120. # logging.debug(f"Passed FASTMATCH: {logline}")
  121. try:
  122. n = PassivesList(logline)
  123. self.container.append(n)
  124. except ValueError as e:
  125. # Line impersonated /passives starting flow
  126. pass
  127. if self.parsed_count <= 10:
  128. logging.info(f"Starting log: {logline}")
  129. self.parsed_count += 1
  130. def print(self, index=-1):
  131. if not self.container:
  132. raise Exception("No data loaded")
  133. c = self.container[index]
  134. if not c:
  135. raise KeyError("Invalid passives index (check skip value?)")
  136. c = self.container[index]
  137. if self.validate(index):
  138. if not c.uncompleted_quests or not len(c.uncompleted_quests):
  139. print("PASSIVES GET!")
  140. else:
  141. uq = c.uncompleted_quests
  142. if "End to Hunger" in uq.keys():
  143. missing = 1 + len(uq.keys())
  144. else:
  145. missing = len(uq.keys())
  146. print(f"Missing passives ({missing}):")
  147. for key, value in c.uncompleted_quests.items():
  148. print("{: <34}{}".format(key, value))
  149. else:
  150. logging.info(f"UnFound: {self.container[index].uncompleted_quests.items()}")
  151. logging.info(f"Found: {self.container[index].completed_quests.items()}")
  152. raise ValueError(
  153. f"Parser didn't find all of the quests for /passive call {self.container[index].name}!"
  154. )
  155. def printall(self):
  156. for i in range(0, len(self.container)):
  157. self.print(i)
  158. print("-----")
  159. def validate(self, index):
  160. return self.container[index].full
  161. def main():
  162. parser = ArgumentParser(
  163. description="reveal which passives are missing",
  164. )
  165. parser.add_argument(
  166. "--file",
  167. "-f",
  168. default="/mnt/c/Program Files (x86)/Grinding Gear Games/Path of Exile/logs/Client.txt",
  169. dest="client_file",
  170. help="path to Client.txt",
  171. )
  172. parser.add_argument(
  173. "--bytes",
  174. type=int,
  175. default=2000000,
  176. help="number of bytes from the end of Client.txt to check",
  177. )
  178. parser.add_argument(
  179. "--skip",
  180. type=int,
  181. default=None,
  182. help="for debugging only: view data for a /passives call other than the most recent",
  183. )
  184. parser.add_argument(
  185. "--debug",
  186. "-d",
  187. action="count",
  188. default=0,
  189. help="can be specified multiple times to increase log level",
  190. )
  191. args = parser.parse_args()
  192. if args.bytes < 1:
  193. raise ArgumentError("--line count must be greater than 1")
  194. if args.debug:
  195. logging.getLogger().setLevel(logging.DEBUG)
  196. # Create datasets
  197. passives = PassivesListContainer()
  198. with open(args.client_file, "r") as f:
  199. # Client.txt stores the whole world, only parse the last ~1MB
  200. size = fstat(f.fileno()).st_size
  201. if size > args.bytes:
  202. f.seek(size - args.bytes)
  203. f.readline() # discard the partial line
  204. for line in f:
  205. passives.parse(line)
  206. # Print dataset
  207. if not args.skip:
  208. passives.print()
  209. elif args.skip == -1:
  210. passives.printall()
  211. else:
  212. passives.print(-1 - args.skip)
  213. if __name__ == "__main__":
  214. main()