File indexing completed on 2024-04-14 03:59:09

0001 # -*- coding: utf-8 -*-
0002 
0003 """Copyright (C) 2009-2016 Wolfgang Rohdewald <wolfgang@rohdewald.de>
0004 
0005 SPDX-License-Identifier: GPL-2.0
0006 
0007 
0008 
0009 
0010 Read the user manual for a description of the interface to this scoring engine
0011 """
0012 
0013 from itertools import chain
0014 import weakref
0015 from hashlib import md5
0016 
0017 from log import dbgIndent, Fmt, fmt
0018 from tile import Tile, TileList
0019 from tilesource import TileSource
0020 from meld import Meld, MeldList
0021 from rule import Score, UsedRule
0022 from common import Debug, StrMixin
0023 from intelligence import AIDefaultAI
0024 from util import callers
0025 from message import Message
0026 
0027 
0028 class Hand(StrMixin):
0029 
0030     """represent the hand to be evaluated.
0031 
0032     lenOffset is
0033       <0 for a short hand
0034       0 for a correct calling hand
0035       1 for a correct winner hand or a long loser hand
0036       >1 for a long winner hand
0037     Of course ignoring bonus tiles and respecting kong replacement tiles.
0038     if there are no kongs, 13 tiles will return 0
0039 
0040     We assume that long hands never happen. For manual scoring, this should
0041     be asserted by the caller after creating the Hand instance. If the Hand
0042     has lenOffset 1 but is no winning hand, the Hand instance will not be
0043     fully evaluated, it is given Score 0 and hand.won == False.
0044 
0045     declaredMelds are those which cannot be changed anymore: Chows, Pungs,
0046     Kongs.
0047 
0048     tilesInHand are those not in declaredMelds
0049 
0050     Only tiles passed in the 'R' substring may be rearranged.
0051 
0052     mjRule is the one out of mjRules with the highest resulting score. Every
0053     hand gets an mjRule even it is not a wining hand, it is the one which
0054     was used for rearranging the hiden tiles to melds.
0055 
0056     suits include dragons and winds."""
0057 
0058     # pylint: disable=too-many-instance-attributes
0059 
0060     indent = 0
0061     class __NotWon(UserWarning):  # pylint: disable=invalid-name
0062 
0063         """should be won but is not a winning hand"""
0064 
0065     def __new__(cls, player, string, prevHand=None):
0066         # pylint: disable=unused-argument
0067         """since a Hand instance is never changed, we can use a cache"""
0068         cache = player.handCache
0069         cacheKey = string
0070         if cacheKey in cache:
0071             result = cache[cacheKey]
0072             player.cacheHits += 1
0073             return result
0074         player.cacheMisses += 1
0075         result = object.__new__(cls)
0076         cache[cacheKey] = result
0077         return result
0078 
0079     def __init__(self, player, string, prevHand=None):
0080         """evaluate string for player. rules are to be applied in any case"""
0081         if hasattr(self, 'string'):
0082             # I am from cache
0083             return
0084 
0085         # shortcuts for speed:
0086         self._player = weakref.ref(player)
0087         self.ruleset = player.game.ruleset
0088         self.intelligence = player.intelligence if player else AIDefaultAI()
0089         self.string = string
0090         self.__robbedTile = Tile.unknown
0091         self.prevHand = prevHand
0092         self.__won = None
0093         self.__score = None
0094         self.__callingHands = None
0095         self.__mjRule = None
0096         self.ruleCache = {}
0097         self.__lastTile = None
0098         self.__lastSource = TileSource.Unknown
0099         self.__announcements = set()
0100         self.__lastMeld = 0
0101         self.__lastMelds = MeldList()
0102         self.tiles = None
0103         self.melds = MeldList()
0104         self.bonusMelds = MeldList()
0105         self.usedRules = []
0106         self.__rest = TileList()
0107         self.__arranged = None
0108 
0109         self.__parseString(string)
0110         self.__won = self.lenOffset == 1 and player.mayWin
0111 
0112         if Debug.hand or (Debug.mahJongg and self.lenOffset == 1):
0113             self.debug(fmt('{callers}',
0114                            callers=callers(exclude=['__init__'])))
0115             Hand.indent += 1
0116             self.debug('New Hand {} lenOffset={}'.format(string, self.lenOffset))
0117 
0118         try:
0119             self.__arrange()
0120             self.__calculate()
0121             self.__arranged = True
0122         except Hand.__NotWon as notwon:
0123             if Debug.mahJongg:
0124                 self.debug(fmt(str(notwon)))
0125             self.__won = False
0126             self.__score = Score()
0127         finally:
0128             self._fixed = True
0129             if Debug.hand or (Debug.mahJongg and self.lenOffset == 1):
0130                 self.debug('Fixing {} {}{}'.format(self, 'won ' if self.won else '', self.score))
0131             Hand.indent -= 1
0132 
0133     def __parseString(self, inString):
0134         """parse the string passed to Hand()"""
0135         # pylint: disable=too-many-branches
0136         tileStrings = []
0137         for part in inString.split():
0138             partId = part[0]
0139             if partId == 'm':
0140                 if len(part) > 1:
0141                     try:
0142                         self.__lastSource = TileSource.byChar[part[1]]
0143                     except KeyError as _:
0144                         raise Exception('{} has unknown lastTile {}'.format(inString, part[1])) from _
0145                     if len(part) > 2:
0146                         self.__announcements = set(part[2])
0147             elif partId == 'L':
0148                 if len(part[1:]) > 8:
0149                     raise Exception(
0150                         'last tile cannot complete a kang:' + inString)
0151                 if len(part) > 3:
0152                     self.__lastMeld = Meld(part[3:])
0153                 self.__lastTile = Tile(part[1:3])
0154             else:
0155                 if part != 'R':
0156                     tileStrings.append(part)
0157         self.bonusMelds, tileStrings = self.__separateBonusMelds(tileStrings)
0158         tileString = ' '.join(tileStrings)
0159         self.tiles = TileList(tileString.replace(' ', '').replace('R', ''))
0160         self.tiles.sort()
0161         for part in tileStrings[:]:
0162             if part[:1] != 'R':
0163                 self.melds.append(Meld(part))
0164                 tileStrings.remove(part)
0165 
0166         self.values = tuple(x.value for x in self.tiles)
0167         self.suits = {x.lowerGroup for x in self.tiles}
0168         self.declaredMelds = MeldList(x for x in self.melds if x.isDeclared)
0169         declaredTiles = list(sum((x for x in self.declaredMelds), []))
0170         self.tilesInHand = TileList(x for x in self.tiles
0171                                     if x not in declaredTiles)
0172         self.lenOffset = (len(self.tiles) - 13
0173                           - sum(x.isKong for x in self.melds))
0174 
0175         assert len(tileStrings) < 2, tileStrings
0176         self.__rest = TileList()
0177         if tileStrings:
0178             self.__rest.extend(TileList(tileStrings[0][1:]))
0179 
0180         last = self.__lastTile
0181         if last and not last.isBonus:
0182             assert last in self.tiles, \
0183                 'lastTile %s is not in hand %s' % (last, str(self))
0184             if self.__lastSource is TileSource.RobbedKong:
0185                 assert self.tiles.count(last.exposed) + \
0186                     self.tiles.count(last.concealed) == 1, (
0187                         'Robbing kong: I cannot have '
0188                         'lastTile %s more than once in %s' % (
0189                             last, ' '.join(self.tiles)))
0190 
0191     @property
0192     def arranged(self):
0193         """readonly"""
0194         return self.__arranged
0195 
0196     @property
0197     def player(self):
0198         """weakref"""
0199         return self._player()
0200 
0201     @property
0202     def ownWind(self):
0203         """for easier usage"""
0204         return self.player.wind
0205 
0206     @property
0207     def roundWind(self):
0208         """for easier usage"""
0209         return self.player.game.roundWind
0210 
0211     def __calculate(self):
0212         """apply rules, calculate score"""
0213         assert not self.__rest, (
0214             'Hand.__calculate expects there to be no rest tiles: %s' % self)
0215         oldWon = self.__won
0216         self.__applyRules()
0217         if len(self.lastMelds) > 1:
0218             self.__applyBestLastMeld()
0219         if self.__won != oldWon:
0220             # if not won after all, this might be a long hand.
0221             # So we might even have to unapply meld rules and
0222             # bonus points. Instead just recompute all again.
0223             # This should only happen with scoring manual games
0224             # and with scoringtest - normally kajongg would not
0225             # let you declare an invalid mah jongg
0226             self.__applyRules()
0227 
0228     def hasTiles(self):
0229         """tiles are assigned to this hand"""
0230         return self.tiles or self.bonusMelds
0231 
0232     @property
0233     def mjRule(self):
0234         """getter"""
0235         return self.__mjRule
0236 
0237     @mjRule.setter
0238     def mjRule(self, value):
0239         """changing mjRule must reset score"""
0240         if self.__mjRule != value:
0241             self.__mjRule = value
0242             self.__score = None
0243 
0244     @property
0245     def lastTile(self):
0246         """compute and cache, readonly"""
0247         return self.__lastTile
0248 
0249     @property
0250     def lastSource(self):
0251         """compute and cache, readonly"""
0252         return self.__lastSource
0253 
0254     @property
0255     def announcements(self):
0256         """compute and cache, readonly"""
0257         return self.__announcements
0258 
0259     @property
0260     def score(self):
0261         """calculate it first if not yet done"""
0262         if self.__score is None and self.__arranged is not None:
0263             self.__score = Score()
0264             self.__calculate()
0265         return self.__score
0266 
0267     @property
0268     def lastMeld(self):
0269         """compute and cache, readonly"""
0270         if self.__lastMeld == 0:
0271             self.__setLastMeld()
0272         return self.__lastMeld
0273 
0274     @property
0275     def lastMelds(self):
0276         """compute and cache, readonly"""
0277         if self.__lastMeld == 0:
0278             self.__setLastMeld()
0279         return self.__lastMelds
0280 
0281     @property
0282     def won(self):
0283         """do we really have a winner hand?"""
0284         return self.__won
0285 
0286     def debug(self, msg):
0287         """try to use Game.debug so we get a nice prefix"""
0288         idPrefix = Fmt.num_encode(hash(self))
0289         if self.prevHand:
0290             idPrefix += '<{}'.format(Fmt.num_encode(hash(self.prevHand)))
0291         idPrefix = 'Hand({})'.format(idPrefix)
0292         self.player.game.debug(' '.join([dbgIndent(self, self.prevHand), idPrefix, msg]))
0293 
0294     def __applyRules(self):
0295         """find out which rules apply, collect in self.usedRules"""
0296         self.usedRules = []
0297         for meld in chain(self.melds, self.bonusMelds):
0298             self.usedRules.extend(UsedRule(x, meld) for x in meld.rules(self))
0299         for rule in self.ruleset.handRules:
0300             if rule.appliesToHand(self):
0301                 self.usedRules.append(UsedRule(rule))
0302 
0303         self.__score = self.__totalScore()
0304 
0305         self.ruleCache.clear()
0306         # do the rest only if we know all tiles of the hand
0307         if Tile.unknown in self.string:
0308             return
0309         if self.__won:
0310             matchingMJRules = self.__maybeMahjongg()
0311             if not matchingMJRules:
0312                 self.__score = Score()
0313                 raise Hand.__NotWon('no matching MJ Rule')
0314             self.__mjRule = matchingMJRules[0]
0315             self.usedRules.append(UsedRule(self.__mjRule))
0316             self.usedRules.extend(self.matchingWinnerRules())
0317             self.__score = self.__totalScore()
0318         else:  # not self.won
0319             loserRules = self.__matchingRules(self.ruleset.loserRules)
0320             if loserRules:
0321                 self.usedRules.extend(UsedRule(x) for x in loserRules)
0322                 self.__score = self.__totalScore()
0323         self.__checkHasExclusiveRules()
0324 
0325     def matchingWinnerRules(self):
0326         """return a list of matching winner rules"""
0327         matching = [UsedRule(x) for x in self.__matchingRules(self.ruleset.winnerRules)]
0328         limitRule = self.maxLimitRule(matching)
0329         return [limitRule] if limitRule else matching
0330 
0331     def __checkHasExclusiveRules(self):
0332         """if we have one, remove all others"""
0333         exclusive = [x for x in self.usedRules if 'absolute' in x.rule.options]
0334         if exclusive:
0335             self.usedRules = exclusive
0336             self.__score = self.__totalScore()
0337             if self.__won and not bool(self.__maybeMahjongg()):
0338                 raise Hand.__NotWon(fmt('exclusive rule {exclusive} does not win'))
0339 
0340     def __setLastMeld(self):
0341         """set the shortest possible last meld. This is
0342         not yet the final choice, see __applyBestLastMeld"""
0343         self.__lastMeld = None
0344         if self.lastTile and self.__won:
0345             if self.mjRule:
0346                 self.__lastMelds = self.mjRule.computeLastMelds(self)
0347                 if self.__lastMelds:
0348                     # syncHandBoard may return nothing
0349                     if len(self.__lastMelds) == 1:
0350                         self.__lastMeld = self.__lastMelds[0]
0351                     else:
0352                         totals = sorted(
0353                             (len(x), idx)
0354                             for idx, x in enumerate(self.__lastMelds))
0355                         self.__lastMeld = self.__lastMelds[totals[0][1]]
0356             if not self.__lastMeld:
0357                 self.__lastMeld = self.lastTile.single
0358                 self.__lastMelds = MeldList(self.__lastMeld)
0359 
0360     def __applyBestLastMeld(self):
0361         """select the last meld giving the highest score
0362         (only winning variants)"""
0363         assert len(self.lastMelds) > 1
0364         totals = []
0365         prev = self.lastMeld
0366         for rule in self.usedRules:
0367             assert isinstance(rule, UsedRule)
0368         for lastMeld in self.lastMelds:
0369             self.__lastMeld = lastMeld
0370             try:
0371                 self.__applyRules()
0372                 totals.append((self.__totalScore().total(), lastMeld))
0373             except Hand.__NotWon:
0374                 pass
0375         if totals:
0376             totals = sorted(totals)  # sort by totalScore
0377             maxScore = totals[-1][0]
0378             totals = [x[1] for x in totals if x[0] == maxScore]
0379             # now we have a list of only lastMelds reaching maximum score
0380             if prev not in totals or self.__lastMeld not in totals:
0381                 if Debug.explain and prev not in totals:
0382                     if not self.player.game.belongsToRobotPlayer():
0383                         self.debug(fmt(
0384                             'replaced last meld {prev} with {totals[0]}'))
0385                 self.__lastMeld = totals[0]
0386                 self.__applyRules()
0387 
0388     def chancesToWin(self):
0389         """count the physical tiles that make us win and still seem available"""
0390         assert self.lenOffset == 0
0391         result = []
0392         for completedHand in self.callingHands:
0393             result.extend(
0394                 [completedHand.lastTile] *
0395                 (self.player.tileAvailable(completedHand.lastTile, self)))
0396         return result
0397 
0398     def newString(self, melds=1, rest=1, lastSource=1, announcements=1, lastTile=1, lastMeld=1):
0399         """create string representing a hand. Default is current Hand, but every part
0400         can be overridden or excluded by passing None"""
0401         if melds == 1:
0402             melds = chain(self.melds, self.bonusMelds)
0403         if rest == 1:
0404             rest = self.__rest
0405         if lastSource == 1:
0406             lastSource = self.lastSource
0407         if announcements == 1:
0408             announcements = self.announcements
0409         if lastTile == 1:
0410             lastTile = self.lastTile
0411         if lastMeld == 1:
0412             lastMeld = self.__lastMeld
0413         parts = [str(x) for x in sorted(melds)]
0414         if rest:
0415             parts.append('R' + ''.join(str(x) for x in sorted(rest)))
0416         if lastSource or announcements:
0417             parts.append('m{}{}'.format(
0418                 self.lastSource.char,
0419                 ''.join(self.announcements)))
0420         if lastTile:
0421             parts.append('L{}{}'.format(lastTile, lastMeld if lastMeld else ''))
0422         return ' '.join(parts).strip()
0423 
0424     def __add__(self, addTile):
0425         """return a new Hand built from this one plus addTile"""
0426         assert addTile.isConcealed, 'addTile %s should be concealed:' % addTile
0427         # combine all parts about hidden tiles plus the new one to one part
0428         # because something like DrDrS8S9 plus S7 will have to be reordered
0429         # anyway
0430         newString = self.newString(
0431             melds=chain(self.declaredMelds, self.bonusMelds),
0432             rest=self.tilesInHand + [addTile],
0433             lastSource=None,
0434             lastTile=addTile,
0435             lastMeld=None
0436             )
0437         return Hand(self.player, newString, prevHand=self)
0438 
0439     def __sub__(self, subtractTile):
0440         """return a copy of self minus subtractTiles.
0441         Case of subtractTile (hidden or exposed) is ignored.
0442         subtractTile must either be undeclared or part of
0443         lastMeld. Exposed melds of length<3 will be hidden."""
0444         # pylint: disable=too-many-branches
0445         # If lastMeld is given, it must be first in the list.
0446         # Next try undeclared melds, then declared melds
0447         assert self.lenOffset == 1
0448         if self.lastTile:
0449             if self.lastTile is subtractTile and self.prevHand:
0450                 return self.prevHand
0451         declaredMelds = self.declaredMelds
0452         tilesInHand = TileList(self.tilesInHand)
0453         boni = MeldList(self.bonusMelds)
0454         lastMeld = self.lastMeld
0455         if subtractTile.isBonus:
0456             for idx, meld in enumerate(boni):
0457                 if subtractTile is meld[0]:
0458                     del boni[idx]
0459                     break
0460         else:
0461             if lastMeld and lastMeld.isDeclared and (
0462                     subtractTile.exposed in lastMeld.exposed):
0463                 declaredMelds.remove(lastMeld)
0464                 tilesInHand.extend(lastMeld.concealed)
0465             tilesInHand.remove(subtractTile.concealed)
0466         for meld in declaredMelds[:]:
0467             if len(meld) < 3:
0468                 declaredMelds.remove(meld)
0469                 tilesInHand.extend(meld.concealed)
0470         # if we robbed a kong, remove that announcement
0471         mjPart = ''
0472         announcements = self.announcements - set('k')
0473         if announcements:
0474             mjPart = 'm.' + ''.join(announcements)
0475         rest = 'R' + str(tilesInHand)
0476         newString = ' '.join(str(x) for x in (
0477             declaredMelds, rest, boni, mjPart))
0478         return Hand(self.player, newString, prevHand=self)
0479 
0480     def manualRuleMayApply(self, rule):
0481         """return True if rule has selectable() and applies to this hand"""
0482         if self.__won and rule in self.ruleset.loserRules:
0483             return False
0484         if not self.__won and rule in self.ruleset.winnerRules:
0485             return False
0486         return rule.selectable(self) or rule.appliesToHand(self)
0487         # needed for activated rules
0488 
0489     @property
0490     def callingHands(self):
0491         """the hand is calling if it only needs one tile for mah jongg.
0492         Returns all hands which would only need one tile.
0493         If mustBeAvailable is True, make sure the missing tile might still
0494         be available.
0495         """
0496         if self.__callingHands is None:
0497             self.__callingHands = self.__findAllCallingHands()
0498         return self.__callingHands
0499 
0500     def __findAllCallingHands(self):
0501         """always try to find all of them"""
0502         result = []
0503         string = self.string
0504         if ' x' in string or self.lenOffset:
0505             return result
0506         candidates = []
0507         for rule in self.ruleset.mjRules:
0508             cand = rule.winningTileCandidates(self)
0509             if Debug.hand and cand:
0510                 # Py2 and Py3 show sets differently
0511                 candis = ''.join(str(x) for x in sorted(cand)) # pylint: disable=unused-variable
0512                 self.debug('callingHands found {} for {}'.format(candis, rule))
0513             candidates.extend(x.concealed for x in cand)
0514         for tile in sorted(set(candidates)):
0515             if sum(x.exposed == tile.exposed for x in self.tiles) == 4:
0516                 continue
0517             hand = self + tile
0518             if hand.won:
0519                 result.append(hand)
0520         if Debug.hand:
0521             _hiderules = ', '.join({x.mjRule.name for x in result})
0522             if _hiderules:
0523                 self.debug(fmt('Is calling {_hiderules}'))
0524         return result
0525 
0526     @property
0527     def robbedTile(self):
0528         """cache this here for use in rulecode"""
0529         if self.__robbedTile is Tile.unknown:
0530             self.__robbedTile = None
0531             if self.player.game.moves:
0532                 # scoringtest does not (yet) simulate this
0533                 lastMove = self.player.game.moves[-1]
0534                 if (lastMove.message == Message.DeclaredKong
0535                         and lastMove.player != self.player):
0536                     self.__robbedTile = lastMove.meld[1]
0537                     # we want it concealed only for a hidden Kong
0538         return self.__robbedTile
0539 
0540     def __maybeMahjongg(self):
0541         """check if this is a mah jongg hand.
0542         Return a sorted list of matching MJ rules, highest
0543         total first. If no rule matches, return None"""
0544         if self.lenOffset == 1 and self.player.mayWin:
0545             matchingMJRules = [x for x in self.ruleset.mjRules
0546                                if x.appliesToHand(self)]
0547             if matchingMJRules:
0548                 if self.robbedTile and self.robbedTile.isConcealed:
0549                     # Millington 58: robbing hidden kong is only
0550                     # allowed for 13 orphans
0551                     matchingMJRules = [
0552                         x for x in matchingMJRules
0553                         if 'mayrobhiddenkong' in x.options]
0554                 result = sorted(matchingMJRules, key=lambda x: -x.score.total())
0555                 if Debug.mahJongg:
0556                     self.debug(fmt('{callers} Found {matchingMJRules}',
0557                                    callers=callers()))
0558                 return result
0559         return None
0560 
0561     def __arrangements(self):
0562         """find all legal arrangements.
0563         Returns a list of tuples with the mjRule and a list of concealed melds"""
0564         self.__rest.sort()
0565         result = []
0566         stdMJ = self.ruleset.standardMJRule
0567         if self.mjRule:
0568             rules = [self.mjRule]
0569         else:
0570             rules = self.ruleset.mjRules
0571         for mjRule in rules:
0572             if ((self.lenOffset == 1 and mjRule.appliesToHand(self))
0573                     or (self.lenOffset < 1 and mjRule.shouldTry(self))):
0574                 if self.__rest:
0575                     for melds, rest2 in mjRule.rearrange(self, self.__rest[:]):
0576                         if rest2:
0577                             melds = list(melds)
0578                             restMelds, _ = next(
0579                                 stdMJ.rearrange(self, rest2[:]))
0580                             melds.extend(restMelds)
0581                         result.append((mjRule, melds))
0582         if not result:
0583             result.extend(
0584                 (stdMJ, x[0])
0585                 for x in stdMJ.rearrange(self, self.__rest[:]))
0586         return result
0587 
0588     def __arrange(self):
0589         """work hard to always return the variant with the highest Mah Jongg value."""
0590         if any(not x.isKnown for x in self.__rest):
0591             melds, rest = divmod(len(self.__rest), 3)
0592             self.melds.extend([Tile.unknown.pung] * melds)
0593             if rest:
0594                 self.melds.append(Meld(Tile.unknown * rest))
0595             self.__rest = []
0596         if not self.__rest:
0597             self.melds.sort()
0598             mjRules = self.__maybeMahjongg()
0599             if self.won:
0600                 if not mjRules:
0601                     # how could this ever happen?
0602                     raise Hand.__NotWon('Long Hand with no rest')
0603                 self.mjRule = mjRules[0]
0604             return
0605         wonHands = []
0606         lostHands = []
0607         for mjRule, melds in self.__arrangements():
0608             allMelds = self.melds[:] + list(melds)
0609             lastTile = self.lastTile
0610             if self.lastSource and self.lastSource.isDiscarded:
0611                 lastTile = lastTile.exposed
0612                 lastMelds = sorted(
0613                     (x for x in allMelds if not x.isDeclared and lastTile.concealed in x),
0614                     key=lambda x: len(x)) # pylint: disable=unnecessary-lambda
0615                 if lastMelds:
0616                     allMelds.remove(lastMelds[0])
0617                     allMelds.append(lastMelds[0].exposed)
0618             _ = self.newString(
0619                 chain(allMelds, self.bonusMelds),
0620                 rest=None, lastTile=lastTile, lastMeld=None)
0621             tryHand = Hand(self.player, _, prevHand=self)
0622             if tryHand.won:
0623                 tryHand.mjRule = mjRule
0624                 wonHands.append((mjRule, melds, tryHand))
0625             else:
0626                 lostHands.append((mjRule, melds, tryHand))
0627         # we prefer a won Hand even if a lost Hand might have a higher score
0628         tryHands = wonHands if wonHands else lostHands
0629         bestRule, bestVariant, _ = max(tryHands, key=lambda x: x[2])
0630         if wonHands:
0631             self.mjRule = bestRule
0632         self.melds.extend(bestVariant)
0633         self.melds.sort()
0634         self.__rest = []
0635         self.ruleCache.clear()
0636         assert sum(len(x) for x in self.melds) == len(self.tiles), (
0637             '%s != %s' % (self.melds, self.tiles))
0638 
0639     def __gt__(self, other):
0640         """compares hand values"""
0641         assert self.player == other.player
0642         if not other.arranged:
0643             return True
0644         if self.won and not (other.arranged and other.won):
0645             return True
0646         if not (self.arranged and self.won) and other.won:
0647             return False
0648         return (self.intelligence.handValue(self)
0649                 > self.intelligence.handValue(other))
0650 
0651     def __lt__(self, other):
0652         """compares hand values"""
0653         return other.__gt__(self)
0654 
0655     def __eq__(self, other):
0656         """compares hand values"""
0657         assert self.player == other.player
0658         return self.string == other.string
0659 
0660     def __ne__(self, other):
0661         """compares hand values"""
0662         assert self.player == other.player
0663         return self.string != other.string
0664 
0665     def __matchingRules(self, rules):
0666         """return all matching rules for this hand"""
0667         return [rule for rule in rules if rule.appliesToHand(self)]
0668 
0669     @staticmethod
0670     def maxLimitRule(usedRules):
0671         """return the rule with the highest limit score or None"""
0672         result = None
0673         maxLimit = 0
0674         usedRules = [x for x in usedRules if x.rule.score.limits]
0675         for usedRule in usedRules:
0676             score = usedRule.rule.score
0677             if score.limits > maxLimit:
0678                 maxLimit = score.limits
0679                 result = usedRule
0680         return result
0681 
0682     def __totalScore(self):
0683         """use all used rules to compute the score"""
0684         maxRule = self.maxLimitRule(self.usedRules)
0685         maxLimit = 0.0
0686         pointsTotal = sum((x.rule.score for x in self.usedRules),
0687                           Score(ruleset=self.ruleset))
0688         if maxRule:
0689             maxLimit = maxRule.rule.score.limits
0690             if (maxLimit >= 1.0
0691                     or maxLimit * self.ruleset.limit > pointsTotal.total()):
0692                 self.usedRules = [maxRule]
0693                 return Score(ruleset=self.ruleset, limits=maxLimit)
0694         return pointsTotal
0695 
0696     def total(self):
0697         """total points of hand"""
0698         return self.score.total()
0699 
0700     @staticmethod
0701     def __separateBonusMelds(tileStrings):
0702         """One meld per bonus tile. Others depend on that."""
0703         bonusMelds = MeldList()
0704         for tileString in tileStrings[:]:
0705             if len(tileString) == 2:
0706                 tile = Tile(tileString)
0707                 if tile.isBonus:
0708                     bonusMelds.append(tile.single)
0709                     tileStrings.remove(tileString)
0710         return bonusMelds, tileStrings
0711 
0712     def explain(self):
0713         """explain what rules were used for this hand"""
0714         usedRules = self.player.sortRulesByX(self.usedRules)
0715         result = [x.rule.explain(x.meld) for x in usedRules
0716                   if x.rule.score.points]
0717         result.extend(
0718             [x.rule.explain(x.meld) for x in usedRules
0719              if x.rule.score.doubles])
0720         result.extend(
0721             [x.rule.explain(x.meld) for x in usedRules
0722              if not x.rule.score.points and not x.rule.score.doubles])
0723         if any(x.rule.debug for x in usedRules):
0724             result.append(str(self))
0725         return result
0726 
0727     def doublesEstimate(self, discard=None):
0728         """this is only an estimate because it only uses meldRules and handRules,
0729         but not things like mjRules, winnerRules, loserRules"""
0730         result = 0
0731         if discard and self.tiles.count(discard) == 2:
0732             melds = chain(self.melds, self.bonusMelds, [discard.exposed.pung])
0733         else:
0734             melds = chain(self.melds, self.bonusMelds)
0735         for meld in melds:
0736             result += sum(x.score.doubles for x in meld.doublingRules(self))
0737         for rule in self.ruleset.doublingHandRules:
0738             if rule.appliesToHand(self):
0739                 result += rule.score.doubles
0740         return result
0741 
0742     def __str__(self):
0743         """hand as a string"""
0744         return self.newString()
0745 
0746     def __hash__(self):
0747         """used for debug logging to identify the hand"""
0748         if not hasattr(self, 'string'):
0749             return 0
0750         md5sum = md5()
0751         md5sum.update(self.player.name.encode('utf-8'))
0752         md5sum.update(self.string.encode())
0753         digest = md5sum.digest()
0754         assert len(digest) == 16
0755         result = 0
0756         for part in range(4):
0757             result = (result << 8) + digest[part]
0758         return result