proxy.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. import re
  2. from twisted.internet import defer
  3. from twisted.internet import protocol
  4. from twisted.internet import reactor
  5. from twisted.internet import task
  6. from twisted.internet.task import coiterate
  7. # from twisted.python import log
  8. import logging
  9. import pendulum
  10. from colorama import Fore, Back, Style
  11. from pprint import pformat
  12. # from config import config, version
  13. import config
  14. log = logging.getLogger(__name__)
  15. def merge(color_string: str):
  16. """ Given a string of colorama ANSI, merge them if you can. """
  17. return color_string.replace("m\x1b[", ";")
  18. # https://en.wikipedia.org/wiki/ANSI_escape_code
  19. # Cleans all ANSI
  20. cleaner = re.compile(r"\x1b\[[0-9;]*[A-Zmh]")
  21. # Looks for ANSI (that should be considered to be a newline)
  22. # This needs to see what is send when something enters / leaves
  23. # the player's current sector. (That doesn't work/isn't
  24. # detected. NNY!) It is "\x1b[K" Erase in Line!
  25. makeNL = re.compile(r"\x1b\[[0-9;]*[JK]")
  26. def treatAsNL(line: str):
  27. """ Replace any ANSI codes that would be better understood as newlines. """
  28. global makeNL
  29. return makeNL.sub("\n", line)
  30. def cleanANSI(line: str):
  31. """ Remove all ANSI codes. """
  32. global cleaner
  33. return cleaner.sub("", line)
  34. # return re.sub(r'\x1b\[([0-9,A-Z]{1,2}(;[0-9]{1,2})?(;[0-9]{3})?)?[m|K]?', '', line)
  35. class UserAdapter(logging.LoggerAdapter):
  36. def process(self, msg, kwargs):
  37. return "[{0}] {1}".format(self.extra["game"].usergame, msg), kwargs
  38. from observer import Observer
  39. from flexible import PlayerInput, ProxyMenu
  40. from galaxy import GameData, PORT_CLASSES, CLASSES_PORT
  41. class Game(protocol.Protocol):
  42. def __init__(self):
  43. self.buffer = ""
  44. self.game = None
  45. self.usergame = (None, None)
  46. self.gamedata = None
  47. self.to_player = True
  48. self.linestate = ""
  49. baselog = logging.getLogger(__name__)
  50. self.log = UserAdapter(baselog, {"game": self})
  51. def connectionMade(self):
  52. self.log.info("Connected to Game Server")
  53. self.queue_player = self.factory.queue_player
  54. self.queue_game = self.factory.queue_game
  55. self.observer = self.factory.observer
  56. self.factory.game = self
  57. self.setPlayerReceived()
  58. self.observer.connect("user-game", self.show_game)
  59. def show_game(self, game: tuple):
  60. self.usergame = game
  61. self.log.info("## User-Game: {0}".format(game))
  62. if game[1] is None:
  63. if self.gamedata is not None:
  64. # start the save
  65. coiterate(self.gamedata.save())
  66. self.gamedata = None
  67. else:
  68. # Load the game data (if any)
  69. self.gamedata = GameData(game)
  70. coiterate(self.gamedata.load())
  71. def setPlayerReceived(self):
  72. """ Get deferred from client queue, callback clientDataReceived. """
  73. self.queue_player.get().addCallback(self.playerDataReceived)
  74. def playerDataReceived(self, chunk):
  75. if chunk is False:
  76. self.queue_player = None
  77. self.log.info("Player: disconnected, close connection to game")
  78. # I don't believe I need this if I'm using protocol.Factory
  79. self.factory.continueTrying = False
  80. self.transport.loseConnection()
  81. else:
  82. # Pass received data to the server
  83. if type(chunk) == str:
  84. self.transport.write(chunk.encode("latin-1"))
  85. log.debug(">> [{0}]".format(chunk))
  86. else:
  87. self.transport.write(chunk)
  88. self.log.debug(">> [{0}]".format(chunk.decode("latin-1", "ignore")))
  89. self.setPlayerReceived()
  90. def warpline(self, line: str):
  91. self.log.debug("warp: " + line)
  92. # 1 > 3 > 5 > 77 > 999
  93. last_sector = self.lastwarp
  94. line = line.replace("(", "").replace(")", "").replace(">", "").strip()
  95. for s in line.split():
  96. # Ok, this should be all of the warps.
  97. sector = int(s)
  98. if last_sector > 0:
  99. self.gamedata.warp_to(last_sector, sector)
  100. last_sector = sector
  101. self.lastwarp = sector
  102. def cimline(self, line: str):
  103. # log.debug(self.linestate, ":", line)
  104. if line[-1] == "%":
  105. self.linestate = "portcim"
  106. if self.linestate == "warpcim":
  107. # warps
  108. work = line.strip()
  109. if work != "":
  110. parts = re.split(r"(?<=\d)\s", work)
  111. parts = [int(x) for x in parts]
  112. sector = parts.pop(0)
  113. self.gamedata.warp_to(sector, *parts)
  114. elif self.linestate == "portcim":
  115. # ports
  116. work = line.replace("%", "")
  117. parts = re.parts = re.split(r"(?<=\d)\s", work)
  118. if len(parts) == 8:
  119. sector = int(parts[0].strip())
  120. data = dict()
  121. def portBS(info):
  122. if info[0] == "-":
  123. bs = "B"
  124. else:
  125. bs = "S"
  126. return (bs, int(info[1:].strip()))
  127. data["fuel"] = dict()
  128. data["fuel"]["sale"], data["fuel"]["units"] = portBS(parts[1])
  129. data["fuel"]["pct"] = int(parts[2].strip())
  130. data["org"] = dict()
  131. data["org"]["sale"], data["org"]["units"] = portBS(parts[3])
  132. data["org"]["pct"] = int(parts[4].strip())
  133. data["equ"] = dict()
  134. data["equ"]["sale"], data["equ"]["units"] = portBS(parts[5])
  135. data["equ"]["pct"] = int(parts[6].strip())
  136. # Store what this port is buying/selling
  137. data["port"] = (
  138. data["fuel"]["sale"] + data["org"]["sale"] + data["equ"]["sale"]
  139. )
  140. # Convert BBS/SBB to Class number 1-8
  141. data["class"] = CLASSES_PORT[data["port"]]
  142. self.gamedata.set_port(sector, data)
  143. else:
  144. self.linestate = "cim"
  145. def sectorline(self, line: str):
  146. self.log.debug("sector: {0} : {1}".format(self.current_sector, line))
  147. if line.startswith("Beacon : "):
  148. pass # get beacon text
  149. elif line.startswith("Ports : "):
  150. # Ports : Ballista, Class 1 (BBS)
  151. self.sector_state = "port"
  152. if "<=-DANGER-=>" in line:
  153. # Port is destroyed
  154. if self.current_sector in self.gamedata.ports:
  155. del self.gamedata.ports[self.current_sector]
  156. # elif "(StarDock)" not in line:
  157. # Ports : Stargate Alpha I, Class 9 (Special) (StarDock)
  158. else:
  159. _, _, class_port = line.partition(", Class ")
  160. c, port = class_port.split(" ", maxsplit=1)
  161. c = int(c)
  162. if "StarDock" in port:
  163. port = "StarDock"
  164. port = port.replace("(", "").replace(")", "")
  165. data = {"port": port, "class": c}
  166. self.gamedata.set_port(self.current_sector, data)
  167. elif line.startswith("Planets : "):
  168. # Planets : (O) Flipper
  169. self.sector_state = "planet"
  170. elif line.startswith("Traders : "):
  171. self.sector_state = "trader"
  172. elif line.startswith("Ships : "):
  173. self.sector_state = "ship"
  174. elif line.startswith("Fighters: "):
  175. self.sector_state = "fighter"
  176. elif line.startswith("NavHaz : "):
  177. pass
  178. elif line.startswith("Mines : "):
  179. self.sector_state = "mine"
  180. elif line.startswith(" "):
  181. # continues
  182. if self.sector_state == "mines":
  183. pass
  184. if self.sector_state == "planet":
  185. pass
  186. if self.sector_state == "trader":
  187. pass
  188. if self.sector_state == "ship":
  189. pass
  190. elif len(line) > 8 and line[8] == ":":
  191. self.sector_state = "normal"
  192. elif line.startswith("Warps to Sector(s) :"):
  193. # Warps to Sector(s) : 5468
  194. _, _, work = line.partition(":")
  195. # TO FIX: We are interested in (unexplored) sectors.
  196. work = work.strip().replace("(", "").replace(")", "").replace(" - ", " ")
  197. parts = [int(x) for x in work.split(" ")]
  198. self.log.debug("Sectorline warps {0}".format(parts))
  199. self.gamedata.warp_to(self.current_sector, *parts)
  200. self.sector_state = "normal"
  201. self.linestate = ""
  202. def portline(self, line: str):
  203. # Map these items to which keys
  204. self.log.debug("portline({0}): {1}".format(self.current_sector, line))
  205. mapto = {"Fuel": "fuel", "Organics": "org", "Equipment": "equ"}
  206. if "%" in line:
  207. # Fuel Ore Buying 2890 100% 0
  208. work = line.replace("Fuel Ore", "Fuel").replace("%", "")
  209. parts = re.split(r"\s+", work)
  210. data = {
  211. mapto[parts[0]]: {
  212. "sale": parts[1][0],
  213. "units": parts[2],
  214. "pct": int(parts[3]),
  215. }
  216. }
  217. # log.debug("Setting {0} to {1}".format(self.current_sector, data))
  218. self.gamedata.set_port(self.current_sector, data)
  219. # log.debug("NOW: {0}".format(self.gamedata.ports[self.current_sector]))
  220. def thiefline(self, line: str):
  221. self.log.debug("thiefline({0}): {1}".format(self.current_sector, line))
  222. if "Suddenly you're Busted!" in line:
  223. # Lets add it into the bust list
  224. self.gamedata.set_bust(self.current_sector)
  225. elif "(You realize the guards saw you last time!)" in line:
  226. self.linestate = ""
  227. def goodbye(self):
  228. # hey hey hey, goodbye!
  229. self.connectionLost("We don't go there.")
  230. def chicken(self):
  231. if not self.received:
  232. self.log.debug("checking ... FAILED (chicken!)")
  233. # this should force the proxy to save
  234. self.observer.emit("user-game", (self.factory.player.user, None))
  235. self.queue_game.put(
  236. "\r\n"
  237. + merge(Style.NORMAL + Fore.MAGENTA)
  238. + "...Now leaving "
  239. + merge(Style.BRIGHT + Fore.BLUE)
  240. + "Trade Wars 2002"
  241. + merge(Style.NORMAL + Fore.MAGENTA)
  242. + " and returning to system."
  243. + Style.RESET_ALL
  244. + "\r\n"
  245. )
  246. reactor.callLater(2, self.goodbye)
  247. else:
  248. self.log.debug("check -- PASSED. WOOT.")
  249. def lineReceived(self, line: str):
  250. """ line received from the game. """
  251. self.received = True
  252. if "log_lines" in config.config and config.config["log_lines"]:
  253. self.log.debug("<< [{0}]".format(line))
  254. if "TradeWars Game Server" in line and "Copyright (C) EIS" in line:
  255. # We are not in a game
  256. if not self.game is None:
  257. # We were in a game.
  258. self.game = None
  259. self.observer.emit("user-game", (self.factory.player.user, self.game))
  260. elif "Selection (? for menu): " in line:
  261. game = line[-1]
  262. if game >= "A" and game < "Q":
  263. self.game = game
  264. log.info("Game: {0}".format(self.game))
  265. self.observer.emit("user-game", (self.factory.player.user, self.game))
  266. elif "Select A or B to Play or Press [Q] To Exit!" in line:
  267. game = line[-1]
  268. if game >= "A" and game < "Q":
  269. self.game = game
  270. log.info("Game: {0}".format(self.game))
  271. self.observer.emit("user-game", (self.factory.player.user, self.game))
  272. elif "Confirmed? (Y/N)? Yes" in line:
  273. # Ok, here's what we going to do.
  274. # Set timer for 5 seconds. If we don't receive anything before that --
  275. # hang up the server connection. :P
  276. # 008c:fixme:file:UnlockFileEx Unimplemented overlapped operation
  277. self.received = False
  278. reactor.callLater(5, self.chicken)
  279. # Process.pas parse line
  280. if line.startswith("Command [TL=]"):
  281. # Ok, get the current sector from this
  282. _, _, sector = line.partition("]:[")
  283. sector, _, _ = sector.partition("]")
  284. self.current_sector = int(sector)
  285. self.log.info("current sector: {0}".format(self.current_sector))
  286. if line.startswith("The shortest path (") or line.startswith(" TO > "):
  287. self.linestate = "warpline"
  288. self.lastwarp = 0
  289. elif line.startswith(" Items Status Trading % of max OnBoard"):
  290. self.linestate = "port"
  291. elif line.startswith("<Thievery>"):
  292. self.linestate = "thievery"
  293. elif self.linestate == "warpline":
  294. if line == "":
  295. self.linestate = ""
  296. else:
  297. self.warpline(line)
  298. elif self.linestate == "portcim" or self.linestate == "warpcim":
  299. if line == ": ENDINTERROG":
  300. self.linestate = ""
  301. elif line == ": ":
  302. self.linestate = "cim"
  303. elif line == "":
  304. self.linestate = ""
  305. else:
  306. if len(line) > 2:
  307. self.cimline(line)
  308. elif self.linestate == "cim":
  309. if line == ": ENDINTERROG" or line == "":
  310. self.linestate = ""
  311. elif len(line) > 2:
  312. if line.rstrip()[-1] == "%":
  313. self.linestate = "portcim"
  314. else:
  315. self.linestate = "warpcim"
  316. self.cimline(line)
  317. elif self.linestate == "thievery":
  318. self.thiefline(line)
  319. # elif line.startswith(": "):
  320. elif line == ": ":
  321. self.linestate = "cim"
  322. elif line.startswith("Sector : "):
  323. # Sector : 2565 in uncharted space.
  324. self.linestate = "sector"
  325. work = line.strip()
  326. parts = re.split(r"\s+", work)
  327. self.current_sector = int(parts[2])
  328. elif self.linestate == "sector":
  329. self.sectorline(line)
  330. elif self.linestate == "port":
  331. if line == "":
  332. self.linestate = ""
  333. else:
  334. self.portline(line)
  335. self.observer.emit("game-line", line)
  336. def getPrompt(self):
  337. """ Return the current prompt, stripped of ANSI. """
  338. return cleanANSI(self.buffer)
  339. def dataReceived(self, chunk):
  340. """ Data received from the Game.
  341. Remove backspaces.
  342. Treat some ANSI codes as NewLine.
  343. Remove ANSI.
  344. Break into lines.
  345. Trim out carriage returns.
  346. Call lineReceived().
  347. "Optionally" pass data to player.
  348. FUTURE: trigger on prompt. [cleanANSI(buffer)]
  349. """
  350. # Store the text into the buffer before we inject into it.
  351. self.buffer += chunk.decode("latin-1", "ignore")
  352. # log.debug("data: [{0}]".format(repr(chunk)))
  353. if b"TWGS v2.20b" in chunk and b"www.eisonline.com" in chunk:
  354. # Ok, we have a possible target.
  355. target = b"www.eisonline.com\n\r"
  356. pos = chunk.find(target)
  357. if pos != -1:
  358. # Found it! Inject!
  359. message = (
  360. "TWGS Proxy build "
  361. + config.version
  362. + ". ~ to activate in game.\n\r"
  363. )
  364. chunk = (
  365. chunk[0 : pos + len(target)]
  366. + message.encode("latin-1")
  367. + chunk[pos + len(target) :]
  368. )
  369. # Sequence error:
  370. # If I don't put the chunk(I received) to the player.
  371. # anything I display -- lineReceive() put() ... would
  372. # be out of order. (I'd be responding -- before it
  373. # was displayed to the user.)
  374. if self.to_player:
  375. self.queue_game.put(chunk)
  376. # self.buffer += chunk.decode("latin-1", "ignore")
  377. #
  378. # Begin processing the buffer
  379. #
  380. # Process any backspaces
  381. while "\b" in self.buffer:
  382. part = self.buffer.partition("\b")
  383. self.buffer = part[0][:-1] + part[2]
  384. # Treat some ANSI codes as a newline
  385. self.buffer = treatAsNL(self.buffer)
  386. # Break into lines
  387. while "\n" in self.buffer:
  388. part = self.buffer.partition("\n")
  389. line = part[0].replace("\r", "")
  390. # Clean ANSI codes from line
  391. line = cleanANSI(line)
  392. self.lineReceived(line)
  393. self.buffer = part[2]
  394. self.observer.emit("prompt", self.getPrompt())
  395. def connectionLost(self, why):
  396. self.log.info("Game connectionLost because: %s" % why)
  397. self.observer.emit("close", why)
  398. self.queue_game.put(False)
  399. self.transport.loseConnection()
  400. class Player(protocol.Protocol):
  401. def __init__(self):
  402. self.buffer = ""
  403. self.user = None
  404. self.observer = Observer()
  405. self.game = None
  406. self.glue = None
  407. def connectionMade(self):
  408. """ connected, setup queues.
  409. queue_player is data from player.
  410. queue_game is data to player. (possibly from game)
  411. """
  412. self.queue_player = defer.DeferredQueue()
  413. self.queue_game = defer.DeferredQueue()
  414. self.setGameReceived()
  415. # Connect GlueFactory to this Player object.
  416. factory = GlueFactory(self)
  417. self.glue = factory
  418. # Make connection to the game server
  419. reactor.connectTCP(config.config["host"], config.config["port"], factory, 5)
  420. def setGameReceived(self):
  421. """ Get deferred from client queue, callback clientDataReceived. """
  422. self.queue_game.get().addCallback(self.gameDataReceived)
  423. def gameDataReceived(self, chunk):
  424. """ Data received from the game. """
  425. # If we have received game data, it has to be connected.
  426. if self.game is None:
  427. self.game = self.glue.game
  428. if chunk is False:
  429. self.transport.loseConnection()
  430. else:
  431. if type(chunk) == bytes:
  432. self.transport.write(chunk)
  433. elif type(chunk) == str:
  434. self.transport.write(chunk.encode("latin-1"))
  435. else:
  436. log.err("gameDataReceived: type ({0}) given!".format(type(chunk)))
  437. self.transport.write(chunk)
  438. self.setGameReceived()
  439. def dataReceived(self, chunk):
  440. if self.user is None:
  441. self.buffer += chunk.decode("latin-1", "ignore")
  442. parts = self.buffer.split("\x00")
  443. if len(parts) >= 5:
  444. # rlogin we have the username
  445. self.user = parts[1]
  446. log.info("User: {0}".format(self.user))
  447. zpos = self.buffer.rindex("\x00")
  448. self.buffer = self.buffer[zpos + 1 :]
  449. # but I don't need the buffer anymore, so:
  450. self.buffer = ""
  451. # Pass user value on to whatever needs it.
  452. self.observer.emit("user", self.user)
  453. # Unfortunately, the ones interested in this don't exist yet.
  454. if not self.observer.emit("player", chunk):
  455. # Was not dispatched. Send to game.
  456. self.queue_player.put(chunk)
  457. else:
  458. # There's an observer. Don't continue.
  459. return
  460. if chunk == b"~":
  461. prompt = self.game.getPrompt()
  462. # Selection (? for menu): (the game server menu)
  463. # Enter your choice: (game menu)
  464. # Command [TL=00:00:00]:[1800] (?=Help)? : <- YES!
  465. # Computer command [TL=00:00:00]:[613] (?=Help)?
  466. # (and others I've yet to see...)
  467. if re.match(r"Command \[TL=.* \(\?=Help\)\? : ", prompt):
  468. menu = ProxyMenu(self.game)
  469. else:
  470. nl = "\n\r"
  471. r = Style.RESET_ALL
  472. log.warn("NNY!")
  473. prompt = self.game.buffer
  474. self.queue_game.put(
  475. r
  476. + nl
  477. + Style.BRIGHT
  478. + "Proxy:"
  479. + Style.RESET_ALL
  480. + " I can't activate at this time."
  481. + nl
  482. )
  483. self.queue_game.put(prompt)
  484. self.queue_player.put("\a")
  485. # self.observer.emit("notyet", prompt)
  486. def connectionLost(self, why):
  487. log.info("lost connection %s" % why)
  488. self.observer.emit("close", why)
  489. self.queue_player.put(False)
  490. def connectionFailed(self, why):
  491. log.error("connectionFailed: %s" % why)
  492. class GlueFactory(protocol.ClientFactory):
  493. # class GlueFactory(protocol.Factory):
  494. maxDelay = 10
  495. protocol = Game
  496. def __init__(self, player: Player):
  497. self.player = player
  498. self.queue_player = player.queue_player
  499. self.queue_game = player.queue_game
  500. self.observer = player.observer
  501. self.game = None
  502. def closeIt(self):
  503. log.info("closeIt")
  504. self.queue_game.put(False)
  505. def getUser(self, user):
  506. log.msg("getUser( %s )" % user)
  507. self.game.logUser(user)
  508. # This was needed when I replaced ClientFactory with Factory.
  509. # def clientConnectionLost(self, connector, why):
  510. # log.debug("clientconnectionlost: %s" % why)
  511. # self.queue_client.put(False)
  512. def clientConnectionFailed(self, connector, why):
  513. log.error("connection to game failed: %s" % why)
  514. self.queue_game.put(b"Sorry! I'm Unable to connect to the game server.\r\n")
  515. # syncterm gets cranky/locks up if we close this here.
  516. # (Because it is still sending rlogin information?)
  517. reactor.callLater(2, self.closeIt)