├── .gitignore ├── Data.py ├── LICENSE ├── README.md ├── TenhouDecoder.py ├── TenhouYaku.py └── tenhou-download-game-xml.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | 45 | # Rope 46 | .ropeproject 47 | 48 | # Django stuff: 49 | *.log 50 | *.pot 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | -------------------------------------------------------------------------------- /Data.py: -------------------------------------------------------------------------------- 1 | def asdata(obj, asdata): 2 | if isinstance(obj, Data): 3 | return obj.asdata(asdata) 4 | elif isinstance(obj, str): 5 | return obj 6 | elif hasattr(obj, '_asdict'): 7 | return asdata(obj._asdict(), asdata) 8 | elif isinstance(obj, dict): 9 | return dict((k, asdata(v, asdata)) for (k, v) in obj.items()) 10 | else: 11 | try: 12 | return list(asdata(child, asdata) for child in obj) 13 | except: 14 | return obj 15 | 16 | class Data: 17 | def asdata(self, asdata = asdata): 18 | return dict((k, asdata(v, asdata)) for (k, v) in self.__dict__.items()) 19 | 20 | def __repr__(self): 21 | return self.asdata().__repr__() 22 | 23 | 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Mark Haines 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | tenhou-log 2 | ========== 3 | 4 | Scripts for downloading logs from tenhou.net 5 | 6 | tenhou-download-game-xml.py 7 | --------------------------- 8 | 9 | Scans flash local storage for tenhou log file names and then downloads the game xml from tenhou.net. 10 | 11 | Log Format 12 | ========== 13 | 14 | ``` 15 | 16 | - seed Seed for RNG for generating walls and dice rolls. 17 | - ref ? 18 | Start of game 19 | - type Lobby type. 20 | - lobby Lobby number. 21 | User list or user reconnect 22 | - n[0-3] Names for each player as URLEncoded UTF-8. 23 | - dan List of ranks for each player. 24 | - rate List of rates for each player. 25 | - sx List of sex ("M" or "F") for each player. 26 | User disconnect 27 | - who Player who disconnected. 28 | Start of round 29 | - oya Dealer 30 | Start of hand 31 | - seed Six element list: 32 | Round number, 33 | Number of combo sticks, 34 | Number of riichi sticks, 35 | First dice minus one, 36 | Second dice minus one, 37 | Dora indicator. 38 | - ten List of scores for each player 39 | - oya Dealer 40 | - hai[0-3] Starting hands as a list of tiles for each player. 41 | <[T-W][0-9]*> Player draws a tile. 42 | <[D-G][0-9]*> Player discards a tile. 43 | Player calls a tile. 44 | - who The player who called the tile. 45 | - m The meld. 46 | Player declares riichi. 47 | - who The player who declared riichi 48 | - step Where the player is in declaring riichi: 49 | 1 -> Called "riichi" 50 | 2 -> Placed point stick on table after discarding. 51 | - ten List of current scores for each player. 52 | New dora indicator. 53 | - hai The new dora indicator tile. 54 | A player won the hand 55 | - who The player who won. 56 | - fromwho Who the winner won from: themselves for tsumo, someone else for ron. 57 | - hai The closed hand of the winner as a list of tiles. 58 | - m The open melds of the winner as a list of melds. 59 | - machi The waits of the winner as a list of tiles. 60 | - doraHai The dora as a list of tiles. 61 | - dorahaiUra The ura dora as a list of tiles. 62 | - yaku List of yaku and their han values. 63 | 0 -> tsumo 64 | 1 -> riichi 65 | 2 -> ippatsu 66 | 3 -> chankan 67 | 4 -> rinshan 68 | 5 -> haitei 69 | 6 -> houtei 70 | 7 -> pinfu 71 | 8 -> tanyao 72 | 9 -> ippeiko 73 | 10-17 -> fanpai 74 | 18-20 -> yakuhai 75 | 21 -> daburi 76 | 22 -> chiitoi 77 | 23 -> chanta 78 | 24 -> itsuu 79 | 25 -> sanshokudoujin 80 | 26 -> sanshokudou 81 | 27 -> sankantsu 82 | 28 -> toitoi 83 | 29 -> sanankou 84 | 30 -> shousangen 85 | 31 -> honrouto 86 | 32 -> ryanpeikou 87 | 33 -> junchan 88 | 34 -> honitsu 89 | 35 -> chinitsu 90 | 52 -> dora 91 | 53 -> uradora 92 | 54 -> akadora 93 | - yakuman List of yakuman. 94 | 36 -> renhou 95 | 37 -> tenhou 96 | 38 -> chihou 97 | 39 -> daisangen 98 | 40,41 -> suuankou 99 | 42 -> tsuiisou 100 | 43 -> ryuuiisou 101 | 44 -> chinrouto 102 | 45,46 -> chuurenpooto 103 | 47,48 -> kokushi 104 | 49 -> daisuushi 105 | 50 -> shousuushi 106 | 51 -> suukantsu 107 | - ten Three element list: 108 | The fu points in the hand, 109 | The point value of the hand, 110 | The limit value of the hand: 111 | 0 -> No limit 112 | 1 -> Mangan 113 | 2 -> Haneman 114 | 3 -> Baiman 115 | 4 -> Sanbaiman 116 | 5 -> Yakuman 117 | - ba Two element list of stick counts: 118 | The number of combo sticks, 119 | The number of riichi sticks. 120 | - sc List of scores and the changes for each player. 121 | - owari Final scores including uma at the end of the game. 122 | The hand ended with a draw 123 | - type The type of draw: 124 | "yao9" -> 9 ends 125 | "reach4" -> Four riichi calls 126 | "ron3" -> Triple ron 127 | "kan4" -> Four kans 128 | "kaze4" -> Same wind discard on first round 129 | "nm" -> Nagashi mangan. 130 | - hai[0-3] The hands revealed by players as a list of tiles. 131 | - ba Two element list of stick counts: 132 | The number of combo sticks, 133 | The number of riichi sticks. 134 | - sc List of scores and the changes for each player. 135 | - owari Final scores including uma at the end of the game. 136 | ``` 137 | 138 | Meld Format 139 | ----------- 140 | 141 | ``` 142 | CHI 143 | 144 | 0 1 145 | 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 146 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 147 | | Base Tile | | | | | | | 148 | | and |0| T2| T1| T0|1|Who| 149 | |Called Tile| | | | | | | 150 | +-----------+-+---+---+---+-+---+ 151 | 152 | Base Tile and Called Tile: 153 | ((Base / 9) * 7 + Base % 9) * 3 + Chi 154 | T[0-2]: 155 | Tile[i] - 4 * i - Base * 4 156 | Who: 157 | Offset of player the tile was called from. 158 | Tile[0-2]: 159 | The tiles in the chi. 160 | Base: 161 | The lowest tile in the chi / 4. 162 | Called: 163 | Which tile out of the three was called. 164 | 165 | PON or CHAKAN 166 | 167 | 0 1 168 | 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 169 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 170 | | Base Tile | | |K|P| | | 171 | | and | 0 | T4|A|O|0|Who| 172 | | Called Tile | | |N|N| | | 173 | +---------------+-+---+-+-+-+---+ 174 | 175 | Base Tile and Called Tile: 176 | Base * 3 + Called 177 | T4: 178 | Tile4 - Base * 4 179 | PON: 180 | Set iff the meld is a pon. 181 | KAN: 182 | Set iff the meld is a pon upgraded to a kan. 183 | Who: 184 | Offset of player the tile was called from. 185 | Tile4: 186 | The tile which is not part of the pon. 187 | Base: 188 | A tile in the pon / 4. 189 | Called: 190 | Which tile out of the three was called. 191 | 192 | KAN 193 | 194 | 0 1 195 | 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 196 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 197 | | Base Tile | | | 198 | | and | 0 |Who| 199 | | Called Tile | | | 200 | +---------------+-+---+-+-+-+---+ 201 | 202 | Base Tile and Called Tile: 203 | Base * 4 + Called 204 | Who: 205 | Offset of player the tile was called from or 0 for a closed kan. 206 | Base: 207 | A tile in the kan / 4. 208 | Called: 209 | Which tile out of the four was called. 210 | 211 | ``` 212 | -------------------------------------------------------------------------------- /TenhouDecoder.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python3 2 | 3 | import xml.etree.ElementTree as etree 4 | import urllib.parse 5 | from Data import Data 6 | 7 | class Tile(Data, int): 8 | UNICODE_TILES = """ 9 | 🀐 🀑 🀒 🀓 🀔 🀕 🀖 🀗 🀘 10 | 🀙 🀚 🀛 🀜 🀝 🀞 🀟 🀠 🀡 11 | 🀇 🀈 🀉 🀊 🀋 🀌 🀍 🀎 🀏 12 | 🀀 🀁 🀂 🀃 13 | 🀆 🀅 🀄 14 | """.split() 15 | 16 | TILES = """ 17 | 1s 2s 3s 4s 5s 6s 7s 8s 9s 18 | 1p 2p 3p 4p 5p 6p 7p 8p 9p 19 | 1m 2m 3m 4m 5m 6m 7m 8m 9m 20 | ew sw ww nw 21 | wd gd rd 22 | """.split() 23 | 24 | def asdata(self, convert = None): 25 | return self.TILES[self // 4] + str(self % 4) 26 | 27 | class Player(Data): 28 | pass 29 | 30 | class Round(Data): 31 | pass 32 | 33 | class Meld(Data): 34 | @classmethod 35 | def decode(Meld, data): 36 | data = int(data) 37 | meld = Meld() 38 | meld.fromPlayer = data & 0x3 39 | if data & 0x4: 40 | meld.decodeChi(data) 41 | elif data & 0x18: 42 | meld.decodePon(data) 43 | elif data & 0x20: 44 | meld.decodeNuki(data) 45 | else: 46 | meld.decodeKan(data) 47 | return meld 48 | 49 | def decodeChi(self, data): 50 | self.type = "chi" 51 | t0, t1, t2 = (data >> 3) & 0x3, (data >> 5) & 0x3, (data >> 7) & 0x3 52 | baseAndCalled = data >> 10 53 | self.called = baseAndCalled % 3 54 | base = baseAndCalled // 3 55 | base = (base // 7) * 9 + base % 7 56 | self.tiles = Tile(t0 + 4 * (base + 0)), Tile(t1 + 4 * (base + 1)), Tile(t2 + 4 * (base + 2)) 57 | 58 | def decodePon(self, data): 59 | t4 = (data >> 5) & 0x3 60 | t0, t1, t2 = ((1,2,3),(0,2,3),(0,1,3),(0,1,2))[t4] 61 | baseAndCalled = data >> 9 62 | self.called = baseAndCalled % 3 63 | base = baseAndCalled // 3 64 | if data & 0x8: 65 | self.type = "pon" 66 | self.tiles = Tile(t0 + 4 * base), Tile(t1 + 4 * base), Tile(t2 + 4 * base) 67 | else: 68 | self.type = "chakan" 69 | self.tiles = Tile(t0 + 4 * base), Tile(t1 + 4 * base), Tile(t2 + 4 * base), Tile(t4 + 4 * base) 70 | 71 | def decodeKan(self, data): 72 | baseAndCalled = data >> 8 73 | if self.fromPlayer: 74 | self.called = baseAndCalled % 4 75 | else: 76 | del self.fromPlayer 77 | base = baseAndCalled // 4 78 | self.type = "kan" 79 | self.tiles = Tile(4 * base), Tile(1 + 4 * base), Tile(2 + 4 * base), Tile(3 + 4 * base) 80 | 81 | def decodeNuki(self, data): 82 | del self.fromPlayer 83 | self.type = "nuki" 84 | self.tiles = Tile(data >> 8) 85 | 86 | class Event(Data): 87 | def __init__(self, events): 88 | events.append(self) 89 | self.type = type(self).__name__ 90 | 91 | class Dora(Event): 92 | pass 93 | 94 | class Draw(Event): 95 | pass 96 | 97 | class Discard(Event): 98 | pass 99 | 100 | class Call(Event): 101 | pass 102 | 103 | class Riichi(Event): 104 | pass 105 | 106 | class Agari(Data): 107 | pass 108 | 109 | class Game(Data): 110 | RANKS = "新人,9級,8級,7級,6級,5級,4級,3級,2級,1級,初段,二段,三段,四段,五段,六段,七段,八段,九段,十段,天鳳位".split(",") 111 | NAMES = "n0,n1,n2,n3".split(",") 112 | HANDS = "hai0,hai1,hai2,hai3".split(",") 113 | ROUND_NAMES = "東1,東2,東3,東4,南1,南2,南3,南4,西1,西2,西3,西4,北1,北2,北3,北4".split(",") 114 | YAKU = ( 115 | # 一飜 116 | 'mentsumo', # 門前清自摸和 117 | 'riichi', # 立直 118 | 'ippatsu', # 一発 119 | 'chankan', # 槍槓 120 | 'rinshan kaihou', # 嶺上開花 121 | 'haitei raoyue', # 海底摸月 122 | 'houtei raoyui', # 河底撈魚 123 | 'pinfu', # 平和 124 | 'tanyao', # 断幺九 125 | 'iipeiko', # 一盃口 126 | # seat winds 127 | 'ton', # 自風 東 128 | 'nan', # 自風 南 129 | 'xia', # 自風 西 130 | 'pei', # 自風 北 131 | # round winds 132 | 'ton', # 場風 東 133 | 'nan', # 場風 南 134 | 'xia', # 場風 西 135 | 'pei', # 場風 北 136 | 'haku', # 役牌 白 137 | 'hatsu', # 役牌 發 138 | 'chun', # 役牌 中 139 | # 二飜 140 | 'daburu riichi', # 両立直 141 | 'chiitoitsu', # 七対子 142 | 'chanta', # 混全帯幺九 143 | 'ittsu', # 一気通貫 144 | 'sanshoku doujun', # 三色同順 145 | 'sanshoku doukou', # 三色同刻 146 | 'sankantsu', # 三槓子 147 | 'toitoi', # 対々和 148 | 'sanankou', # 三暗刻 149 | 'shousangen', # 小三元 150 | 'honroutou', # 混老頭 151 | # 三飜 152 | 'ryanpeikou', # 二盃口 153 | 'junchan', # 純全帯幺九 154 | 'honitsu', # 混一色 155 | # 六飜 156 | 'chinitsu', # 清一色 157 | # 満貫 158 | 'renhou', # 人和 159 | # 役満 160 | 'tenhou', # 天和 161 | 'chihou', # 地和 162 | 'daisangen', # 大三元 163 | 'suuankou', # 四暗刻 164 | 'suuankou tanki', # 四暗刻単騎 165 | 'tsuuiisou', # 字一色 166 | 'ryuuiisou', # 緑一色 167 | 'chinroutou', # 清老頭 168 | 'chuuren pouto', # 九蓮宝燈 169 | 'chuuren pouto 9-wait', # 純正九蓮宝燈 170 | 'kokushi musou', # 国士無双 171 | 'kokushi musou 13-wait', # 国士無双13面 172 | 'daisuushi', # 大四喜 173 | 'shousuushi', # 小四喜 174 | 'suukantsu', # 四槓子 175 | # 懸賞役 176 | 'dora', # ドラ 177 | 'uradora', # 裏ドラ 178 | 'akadora', # 赤ドラ 179 | ) 180 | LIMITS=",mangan,haneman,baiman,sanbaiman,yakuman".split(",") 181 | 182 | TAGS = {} 183 | 184 | def tagGO(self, tag, data): 185 | self.gameType = data["type"] 186 | # The attribute was introduced at some point between 187 | # 2010 and 2012: 188 | self.lobby = data.get("lobby") 189 | 190 | def tagUN(self, tag, data): 191 | if "dan" in data: 192 | for name in self.NAMES: 193 | # An empty name, along with sex C, rank 0 and rate 1500 are 194 | # used as placeholders in the fourth player fields in 195 | # three-player games 196 | if data[name]: 197 | player = Player() 198 | player.name = urllib.parse.unquote(data[name]) 199 | self.players.append(player) 200 | ranks = self.decodeList(data["dan"]) 201 | sexes = self.decodeList(data["sx"], dtype = str) 202 | rates = self.decodeList(data["rate"], dtype = float) 203 | for (player, rank, sex, rate) in zip(self.players, ranks, sexes, rates): 204 | player.rank = self.RANKS[rank] 205 | player.sex = sex 206 | player.rate = rate 207 | player.connected = True 208 | else: 209 | for (player, name) in zip(self.players, self.NAMES): 210 | if name in data: 211 | player.connected = True 212 | 213 | def tagBYE(self, tag, data): 214 | self.players[int(data["who"])].connected = False 215 | 216 | def tagINIT(self, tag, data): 217 | self.round = Round() 218 | self.rounds.append(self.round) 219 | name, combo, riichi, d0, d1, dora = self.decodeList(data["seed"]) 220 | self.round.round = self.ROUND_NAMES[name], combo, riichi 221 | self.round.hands = tuple(self.decodeList(data[hand], Tile) for hand in self.HANDS if hand in data and data[hand]) 222 | self.round.dealer = int(data["oya"]) 223 | self.round.events = [] 224 | self.round.agari = [] 225 | self.round.ryuukyoku = False 226 | self.round.ryuukyoku_tenpai = None 227 | Dora(self.round.events).tile = Tile(dora) 228 | 229 | def tagN(self, tag, data): 230 | call = Call(self.round.events) 231 | call.meld = Meld.decode(data["m"]) 232 | call.player = int(data["who"]) 233 | 234 | def tagTAIKYOKU(self, tag, data): 235 | pass 236 | 237 | def tagDORA(self, tag, data): 238 | Dora(self.round.events).tile = int(data["hai"]) 239 | 240 | def tagRYUUKYOKU(self, tag, data): 241 | self.round.ryuukyoku = True 242 | if 'owari' in data: 243 | self.owari = data['owari'] 244 | # For special ryuukyoku types, set to string ID rather than boolean 245 | if 'type' in data: 246 | self.round.ryuukyoku = data['type'] 247 | if self.round.ryuukyoku is True or self.round.ryuukyoku == "nm": 248 | tenpai = self.round.ryuukyoku_tenpai = [] 249 | for index, attr_name in enumerate(self.HANDS): 250 | if attr_name in data: 251 | tenpai.append(index) 252 | 253 | def tagAGARI(self, tag, data): 254 | agari = Agari() 255 | self.round.agari.append(agari) 256 | agari.type = "RON" if data["fromWho"] != data["who"] else "TSUMO" 257 | agari.player = int(data["who"]) 258 | agari.hand = self.decodeList(data["hai"], Tile) 259 | 260 | agari.fu, agari.points, limit = self.decodeList(data["ten"]) 261 | if limit: 262 | agari.limit = self.LIMITS[limit] 263 | agari.dora = self.decodeList(data["doraHai"], Tile) 264 | agari.machi = self.decodeList(data["machi"], Tile) 265 | if "m" in data: 266 | agari.melds = self.decodeList(data["m"], Meld.decode) 267 | agari.closed = all(not hasattr(meld, "fromPlayer") for meld in agari.melds) 268 | else: 269 | agari.closed = True 270 | if "dorahaiUra" in data: 271 | agari.uradora = self.decodeList(data["uradoraHai"], Tile) 272 | if agari.type == "RON": 273 | agari.fromPlayer = int(data["fromWho"]) 274 | if "yaku" in data: 275 | yakuList = self.decodeList(data["yaku"]) 276 | agari.yaku = tuple((self.YAKU[yaku],han) for yaku,han in zip(yakuList[::2], yakuList[1::2])) 277 | elif "yakuman" in data: 278 | agari.yakuman = tuple(self.YAKU[yaku] for yaku in self.decodeList(data["yakuman"])) 279 | if 'owari' in data: 280 | self.owari = data['owari'] 281 | 282 | @staticmethod 283 | def default(self, tag, data): 284 | if tag[0] in "DEFG": 285 | discard = Discard(self.round.events) 286 | discard.tile = Tile(tag[1:]) 287 | discard.player = ord(tag[0]) - ord("D") 288 | discard.connected = self.players[discard.player].connected 289 | elif tag[0] in "TUVW": 290 | draw = Draw(self.round.events) 291 | draw.tile = Tile(tag[1:]) 292 | draw.player = ord(tag[0]) - ord("T") 293 | else: 294 | pass 295 | 296 | @staticmethod 297 | def decodeList(list, dtype = int): 298 | return tuple(dtype(i) for i in list.split(",")) 299 | 300 | def decode(self, log): 301 | events = etree.parse(log).getroot() 302 | self.rounds = [] 303 | self.players = [] 304 | for event in events: 305 | self.TAGS.get(event.tag, self.default)(self, event.tag, event.attrib) 306 | del self.round 307 | 308 | for key in Game.__dict__: 309 | if key.startswith('tag'): 310 | Game.TAGS[key[3:]] = getattr(Game, key) 311 | 312 | if __name__=='__main__': 313 | import yaml 314 | import sys 315 | for path in sys.argv[1:]: 316 | game = Game() 317 | game.decode(open(path)) 318 | yaml.dump(game.asdata(), sys.stdout, default_flow_style=False, allow_unicode=True) 319 | -------------------------------------------------------------------------------- /TenhouYaku.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python3 2 | 3 | import TenhouDecoder 4 | import collections 5 | from Data import Data 6 | 7 | YakuHanCounter = collections.namedtuple('YakuHanCounter', 'yaku han') 8 | 9 | class YakuCounter(Data): 10 | def __init__(self): 11 | self.hands = collections.Counter() 12 | self.closed = YakuHanCounter(collections.Counter(), collections.Counter()) 13 | self.opened = YakuHanCounter(collections.Counter(), collections.Counter()) 14 | self.all = YakuHanCounter(collections.Counter(), collections.Counter()) 15 | 16 | def addGame(self, game): 17 | for round in game.rounds: 18 | self.addRound(round) 19 | 20 | def addRound(self, round): 21 | for agari in round.agari: 22 | self.addAgari(agari) 23 | 24 | def addAgari(self, agari): 25 | counterYaku, counterHan = self.closed if agari.closed else self.opened 26 | allCounterYaku, allCounterHan = self.all 27 | self.hands["closed" if agari.closed else "opened"] += 1 28 | if hasattr(agari, 'yaku'): 29 | for yaku, han in agari.yaku.items(): 30 | counterYaku[yaku] += 1 31 | counterHan[yaku] += han 32 | allCounterYaku[yaku] += 1 33 | allCounterHan[yaku] += han 34 | 35 | if __name__ == '__main__': 36 | import sys 37 | import yaml 38 | counter = YakuCounter() 39 | for path in sys.argv[1:]: 40 | game = TenhouDecoder.Game() 41 | print(path) 42 | game.decode(open(path)) 43 | counter.addGame(game) 44 | yaml.dump(counter.asdata(), sys.stdout, default_flow_style=False, allow_unicode=True) 45 | 46 | -------------------------------------------------------------------------------- /tenhou-download-game-xml.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import glob 4 | import os 5 | from optparse import OptionParser 6 | from struct import Struct 7 | from urllib.parse import parse_qs 8 | from urllib.request import urlopen 9 | from urllib.error import HTTPError 10 | import struct 11 | import codecs 12 | 13 | table = [ 14 | 22136, 52719, 55146, 42104, 15 | 59591, 46934, 9248, 28891, 16 | 49597, 52974, 62844, 4015, 17 | 18311, 50730, 43056, 17939, 18 | 64838, 38145, 27008, 39128, 19 | 35652, 63407, 65535, 23473, 20 | 35164, 55230, 27536, 4386, 21 | 64920, 29075, 42617, 17294, 22 | 18868, 2081 23 | ] 24 | 25 | def tenhouHash(game): 26 | code_pos = game.rindex("-") + 1 27 | code = game[code_pos:] 28 | if code[0] == 'x': 29 | a,b,c = struct.unpack(">HHH", bytes.fromhex(code[1:])) 30 | index = 0 31 | if game[:12] > "2010041111gm": 32 | x = int("3" + game[4:10]) 33 | y = int(game[9]) 34 | index = x % (33 - y) 35 | first = (a ^ b ^ table[index]) & 0xFFFF 36 | second = (b ^ c ^ table[index] ^ table[index + 1]) & 0xFFFF 37 | return game[:code_pos] + codecs.getencoder('hex_codec')(struct.pack(">HH", first, second))[0].decode('ASCII') 38 | else: 39 | return game 40 | 41 | p = OptionParser() 42 | p.add_option('-d', '--directory', 43 | default=os.path.expanduser('~/.tenhou-game-xml'), 44 | help='Directory in which to store downloaded XML') 45 | opts, args = p.parse_args() 46 | if args: 47 | p.error('This command takes no positional arguments') 48 | 49 | sol_files = [] 50 | sol_files.extend(glob.glob(os.path.join( 51 | os.path.expanduser('~'), 52 | '.config/chromium/*/Pepper Data/Shockwave Flash/WritableRoot/#SharedObjects/*/mjv.jp/mjinfo.sol'))) 53 | sol_files.extend(glob.glob(os.path.join( 54 | os.path.expanduser('~'), 55 | '.config/google-chrome/*/Pepper Data/Shockwave Flash/WritableRoot/#SharedObjects/*/mjv.jp/mjinfo.sol'))) 56 | sol_files.extend(glob.glob(os.path.join( 57 | os.path.expanduser('~'), 58 | '.macromedia/Flash_Player/#SharedObjects/*/mjv.jp/mjinfo.sol'))) 59 | # mac os 60 | sol_files.extend(glob.glob(os.path.join( 61 | os.path.expanduser('~'), 62 | 'Library/Application Support/Google/Chrome/Default/Pepper Data/Shockwave Flash/WritableRoot/#SharedObjects/*/mjv.jp/mjinfo.sol'))) 63 | 64 | if not os.path.exists(opts.directory): 65 | os.makedirs(opts.directory) 66 | 67 | for sol_file in sol_files: 68 | print("Reading Flash state file: {}".format(sol_file)) 69 | with open(sol_file, 'rb') as f: 70 | data = f.read() 71 | # What follows is a limited parser for Flash Local Shared Object files - 72 | # a more complete implementation may be found at: 73 | # https://pypi.python.org/pypi/PyAMF 74 | header = Struct('>HI10s8sI') 75 | magic, objlength, magic2, mjinfo, padding = header.unpack_from(data) 76 | offset = header.size 77 | assert magic == 0xbf 78 | assert magic2 == b'TCSO\0\x04\0\0\0\0' 79 | assert mjinfo == b'\0\x06mjinfo' 80 | assert padding == 0 81 | ushort = Struct('>H') 82 | ubyte = Struct('>B') 83 | while offset < len(data): 84 | length, = ushort.unpack_from(data, offset) 85 | offset += ushort.size 86 | name = data[offset:offset+length] 87 | offset += length 88 | amf0_type, = ubyte.unpack_from(data, offset) 89 | offset += ubyte.size 90 | # Type 2: UTF-8 String, prefixed with 2-byte length 91 | if amf0_type == 2: 92 | length, = ushort.unpack_from(data, offset) 93 | offset += ushort.size 94 | value = data[offset:offset+length] 95 | offset += length 96 | # Type 6: Undefined 97 | elif amf0_type == 6: 98 | value = None 99 | # Type 1: Boolean 100 | elif amf0_type == 1: 101 | value = bool(data[offset]) 102 | offset += 1 103 | # Other types from the AMF0 specification are not implemented, as they 104 | # have not been observed in mjinfo.sol files. If required, see 105 | # http://download.macromedia.com/pub/labs/amf/amf0_spec_121207.pdf 106 | else: 107 | print("Unimplemented AMF0 type {} at offset={} (hex {})".format(amf0_type, offset, hex(offset))) 108 | trailer_byte = data[offset] 109 | assert trailer_byte == 0 110 | offset += 1 111 | if name == b'logstr': 112 | loglines = filter(None, value.split(b'\n')) 113 | 114 | for logline in loglines: 115 | logname = parse_qs(logline.decode('ASCII'))['file'][0] 116 | logname = tenhouHash(logname) 117 | target_fname = os.path.join(opts.directory, "{}.xml".format(logname)) 118 | if os.path.exists(target_fname): 119 | print("Game {} already downloaded".format(logname)) 120 | else: 121 | print("Downloading game {}".format(logname)) 122 | try: 123 | resp = urlopen('http://e.mjv.jp/0/log/?' + logname) 124 | data = resp.read() 125 | with open(target_fname, 'wb') as f: 126 | f.write(data) 127 | except HTTPError as e: 128 | if e.code == 404: 129 | print("Could not download game {}. Is the game still in progress?".format(logname)) 130 | else: 131 | raise 132 | --------------------------------------------------------------------------------