File indexing completed on 2024-04-21 04:01:49

0001 # -*- coding: utf-8 -*-
0002 
0003 """
0004 Copyright (C) 2009-2016 Wolfgang Rohdewald <wolfgang@rohdewald.de>
0005 
0006 SPDX-License-Identifier: GPL-2.0
0007 
0008 """
0009 
0010 import weakref
0011 
0012 from message import Message
0013 from common import IntDict, Debug, StrMixin
0014 from tile import Tile
0015 
0016 
0017 class AIDefaultAI:
0018 
0019     """all AI code should go in here"""
0020 
0021     groupPrefs = dict(zip(Tile.colors + Tile.honors, (0, 0, 0, 4, 7)))
0022 
0023     # pylint: disable=no-self-use
0024     # we could solve this by moving those filters into DiscardCandidates
0025     # but that would make it more complicated to define alternative AIs
0026 
0027     def __init__(self, player=None):
0028         self._player = weakref.ref(player) if player else None
0029 
0030     @property
0031     def player(self):
0032         """hide weakref"""
0033         return self._player() if self._player else None  # pylint: disable=not-callable
0034 
0035     def name(self):
0036         """return our name"""
0037         return self.__class__.__name__[2:]
0038 
0039     @staticmethod
0040     def weighSameColors(unusedAiInstance, candidates):
0041         """weigh tiles of same group against each other"""
0042         for candidate in candidates:
0043             if candidate.group in Tile.colors:
0044                 if candidate.prev.occurrence:
0045                     candidate.prev.keep += 1.001
0046                     candidate.keep += 1.002
0047                     if candidate.next.occurrence:
0048                         candidate.prev.keep += 2.001
0049                         candidate.next.keep += 2.003
0050                 if candidate.next.occurrence:
0051                     candidate.next.keep += 1.003
0052                     candidate.keep += 1.002
0053                 elif candidate.next2.occurrence:
0054                     candidate.keep += 0.502
0055                     candidate.next2.keep += 0.503
0056         return candidates
0057 
0058     def selectDiscard(self, hand):
0059         # pylint: disable=too-many-branches, R0915
0060         # disable warning about too many branches
0061         """return exactly one tile for discard.
0062         Much of this is just trial and success - trying to get as much AI
0063         as possible with limited computing resources, it stands on
0064         no theoretical basis"""
0065         candidates = DiscardCandidates(self.player, hand)
0066         result = self.weighDiscardCandidates(candidates).best()
0067         candidates.unlink()
0068         return result
0069 
0070     def weighDiscardCandidates(self, candidates):
0071         """the standard"""
0072         game = self.player.game
0073         weighRules = game.ruleset.filterRules('weigh')
0074         for aiFilter in [self.weighBasics, self.weighSameColors,
0075                          self.weighSpecialGames, self.weighCallingHand,
0076                          self.weighOriginalCall,
0077                          self.alternativeFilter] + weighRules:
0078             if aiFilter in weighRules:
0079                 filterName = aiFilter.__class__.__name__
0080                 aiFilter = aiFilter.weigh
0081             else:
0082                 filterName = aiFilter.__name__
0083             if Debug.robotAI:
0084                 prevWeights = ((x.tile, x.keep) for x in candidates)
0085                 candidates = aiFilter(self, candidates)
0086                 newWeights = ((x.tile, x.keep) for x in candidates)
0087                 for oldW, newW in zip(prevWeights, newWeights):
0088                     if oldW != newW:
0089                         game.debug('%s: %s: %.3f->%.3f' % (
0090                             filterName, oldW[0], oldW[1], newW[1]))
0091             else:
0092                 candidates = aiFilter(self, candidates)
0093         return candidates
0094 
0095     @staticmethod
0096     def alternativeFilter(unusedAiInstance, candidates):
0097         """if the alternative AI only adds tests without changing
0098         default filters, you can override this one to minimize
0099         the source size of the alternative AI"""
0100         return candidates
0101 
0102     @staticmethod
0103     def weighBasics(aiInstance, candidates):
0104         """basic things"""
0105         # pylint: disable=too-many-branches
0106         # too many branches
0107         for candidate in candidates:
0108             keep = candidate.keep
0109             tile = candidate.tile
0110             value = tile.value
0111             if candidate.dangerous:
0112                 keep += 1000
0113             if candidate.occurrence >= 3:
0114                 keep += 10.04
0115             elif candidate.occurrence == 2:
0116                 keep += 5.08
0117             keep += aiInstance.groupPrefs[tile.group]
0118             if tile.isWind:
0119                 if value == candidates.hand.ownWind:
0120                     keep += 1.01
0121                 if value == candidates.hand.roundWind:
0122                     keep += 1.02
0123             if tile.isTerminal:
0124                 keep += 2.16
0125             if candidate.maxPossible == 1:
0126                 if tile.isHonor:
0127                     keep -= 8.32
0128                     # not too much, other players might profit from this tile
0129                 else:
0130                     if not candidate.next.maxPossible:
0131                         if not candidate.prev.maxPossible or not candidate.prev2.maxPossible:
0132                             keep -= 100
0133                     if not candidate.prev.maxPossible:
0134                         if not candidate.next.maxPossible or not candidate.next2.maxPossible:
0135                             keep -= 100
0136             if candidate.available == 1 and candidate.occurrence == 1:
0137                 if tile.isHonor:
0138                     keep -= 3.64
0139                 else:
0140                     if not candidate.next.maxPossible:
0141                         if not candidate.prev.maxPossible or not candidate.prev2.maxPossible:
0142                             keep -= 3.64
0143                     if not candidate.prev.maxPossible:
0144                         if not candidate.next.maxPossible or not candidate.next2.maxPossible:
0145                             keep -= 3.64
0146             candidate.keep = keep
0147         return candidates
0148 
0149     @staticmethod
0150     def weighSpecialGames(unusedAiInstance, candidates):
0151         """like color game, many dragons, many winds"""
0152         # pylint: disable=too-many-nested-blocks
0153         for candidate in candidates:
0154             tile = candidate.tile
0155             groupCount = candidates.groupCounts[tile.group]
0156             if tile.isWind:
0157                 if groupCount > 8:
0158                     candidate.keep += 10.153
0159             elif tile.isDragon:
0160                 if groupCount > 7:
0161                     candidate.keep += 15.157
0162             else:
0163                 # count tiles with a different group:
0164                 if groupCount == 1:
0165                     candidate.keep -= 2.013
0166                 else:
0167                     otherGC = sum(candidates.groupCounts[x]
0168                                   for x in Tile.colors if x != tile.group)
0169                     if otherGC:
0170                         if groupCount > 8 or otherGC < 5:
0171                             # do not go for color game if we already declared
0172                             # something in another group:
0173                             if not any(candidates.declaredGroupCounts[x] for x in Tile.colors if x != tile.group):
0174                                 candidate.keep += 20 // otherGC
0175         return candidates
0176 
0177     @staticmethod
0178     def weighOriginalCall(aiInstance, candidates):
0179         """if we declared Original Call, respect it"""
0180         myself = aiInstance.player
0181         game = myself.game
0182         if myself.originalCall and myself.mayWin:
0183             if Debug.originalCall:
0184                 game.debug('weighOriginalCall: lastTile=%s, candidates=%s' %
0185                            (myself.lastTile, [str(x) for x in candidates]))
0186             for candidate in candidates:
0187                 if candidate.tile is myself.lastTile.exposed:
0188                     winningTiles = myself.originalCallingHand.chancesToWin()
0189                     if Debug.originalCall:
0190                         game.debug('weighOriginalCall: winningTiles=%s for %s' %
0191                                    (winningTiles, str(myself.originalCallingHand)))
0192                         game.debug('weighOriginalCall respects originalCall: %s with %d' %
0193                                    (candidate.tile, -99 * len(winningTiles)))
0194                     candidate.keep -= 99 * len(winningTiles)
0195         return candidates
0196 
0197     @staticmethod
0198     def weighCallingHand(aiInstance, candidates):
0199         """if we can get a calling hand, prefer that"""
0200         for candidate in candidates:
0201             newHand = candidates.hand - candidate.tile.concealed
0202             winningTiles = newHand.chancesToWin()
0203             if winningTiles:
0204                 for winnerTile in sorted(set(winningTiles)):
0205                     winnerHand = newHand + winnerTile.concealed
0206                     if Debug.robotAI:
0207                         aiInstance.player.game.debug('weighCallingHand %s cand %s winnerTile %s winnerHand %s: %s' % (
0208                             newHand, candidate, winnerTile, winnerHand, '     '.join(winnerHand.explain())))
0209                     keep = winnerHand.total() / 10.017
0210                     candidate.keep -= keep
0211                     if Debug.robotAI:
0212                         aiInstance.player.game.debug(
0213                             'weighCallingHand %s winnerTile %s: discardCandidate %s keep -= %.4f' % (
0214                                 newHand, winnerTile, candidate, keep))
0215                 # more weight if we have several chances to win
0216                 candidate.keep -= float(len(winningTiles)) / len(
0217                     set(winningTiles)) * 5.031
0218                 if Debug.robotAI:
0219                     aiInstance.player.game.debug('weighCallingHand %s for %s winningTiles:%s' % (
0220                         newHand, candidates.hand, winningTiles))
0221         return candidates
0222 
0223     def selectAnswer(self, answers):
0224         """this is where the robot AI should go.
0225         Returns answer and one parameter"""
0226         # pylint: disable=too-many-branches
0227         # disable warning about too many branches
0228         answer = parameter = None
0229         tryAnswers = (
0230             x for x in [Message.MahJongg, Message.OriginalCall, Message.Kong,
0231                         Message.Pung, Message.Chow, Message.Discard] if x in answers)
0232         hand = self.player.hand
0233         claimness = IntDict()
0234         discard = self.player.game.lastDiscard
0235         if discard:
0236             for rule in self.player.game.ruleset.filterRules('claimness'):
0237                 claimness += rule.claimness(hand, discard)
0238                 if Debug.robotAI:
0239                     hand.debug(
0240                         '%s: claimness in selectAnswer:%s' %
0241                         (rule.name, claimness))
0242         for tryAnswer in tryAnswers:
0243             parameter = self.player.sayable[tryAnswer]
0244             if not parameter:
0245                 continue
0246             if claimness[tryAnswer] < 0:
0247                 continue
0248             if tryAnswer in [Message.Discard, Message.OriginalCall]:
0249                 parameter = self.selectDiscard(hand)
0250             elif tryAnswer == Message.Pung and self.player.maybeDangerous(tryAnswer):
0251                 continue
0252             elif tryAnswer == Message.Chow:
0253                 parameter = self.selectChow(parameter)
0254             elif tryAnswer == Message.Kong:
0255                 parameter = self.selectKong(parameter)
0256             if parameter:
0257                 answer = tryAnswer
0258                 break
0259         if not answer:
0260             answer = answers[0]  # for now always return default answer
0261         return answer, parameter
0262 
0263     def selectChow(self, chows):
0264         """selects a chow to be completed. Add more AI here."""
0265         for chow in chows:
0266             # a robot should never play dangerous
0267             if not self.player.mustPlayDangerous(chow):
0268                 if not self.player.hasConcealedTiles(chow):
0269                     # do not dissolve an existing chow
0270                     belongsToPair = False
0271                     for tile in chow:
0272                         if self.player.concealedTiles.count(tile) == 2:
0273                             belongsToPair = True
0274                             break
0275                     if not belongsToPair:
0276                         return chow
0277         return None
0278 
0279     def selectKong(self, kongs):
0280         """selects a kong to be declared. Having more than one undeclared kong is quite improbable"""
0281         for kong in kongs:
0282             if not self.player.mustPlayDangerous(kong):
0283                 return kong
0284         return None
0285 
0286     def handValue(self, hand):
0287         """compute the value of a hand.
0288         This is not just its current score but also
0289         what possibilities to evolve it has. E.g. if
0290         only one tile is concealed and 3 of it are already
0291         visible, chances for MJ are 0.
0292         This will become the central part of AI -
0293         moves will be done which optimize the hand value.
0294         For now it is only used by Hand.__split but not
0295         by the actual discarding code"""
0296         return hand.total()
0297 
0298 # the rest is not yet used: __split only wants something nice for
0299 # display but is not relevant for the real decision making
0300 #        if hand is None:
0301 #            hand = self.player.hand
0302 #        result = 0
0303 #        if hand.won:
0304 #            return 1000 + hand.total()
0305 #        result = hand.total()
0306 #        if hand.callingHands:
0307 #            result += 500 + len(hand.callingHands) * 20
0308 #        for meld in hand.declaredMelds:
0309 #            if not meld.isChow:
0310 #                result += 40
0311 #        garbage = []
0312 #        for meld in (x for x in hand.melds if not x.isDeclared):
0313 #            assert len(meld) < 4, hand
0314 #            if meld.isPung:
0315 #                result += 50
0316 #            elif meld.isChow:
0317 #                result += 30
0318 #            elif meld.isPair:
0319 #                result += 5
0320 #            else:
0321 #                garbage.append(meld)
0322 #        return result
0323 
0324 
0325 class TileAI(StrMixin):
0326 
0327     """holds a few AI related tile properties"""
0328     # pylint: disable=too-many-instance-attributes
0329     # we do want that many instance attributes
0330 
0331     def __init__(self, candidates, tile):
0332         self.tile = tile
0333         self.group, self.value = tile.group, tile.value
0334         if tile.isReal:
0335             self.occurrence = candidates.hiddenTiles.count(tile)
0336             self.available = candidates.player.tileAvailable(
0337                 tile, candidates.hand)
0338             self.maxPossible = self.available + self.occurrence
0339             self.dangerous = bool(
0340                 candidates.player.game.dangerousFor(
0341                     candidates.player,
0342                     tile))
0343         else:
0344             # value might be -1, 0, 10, 11 for suits
0345             self.occurrence = 0
0346             self.available = 0
0347             self.maxPossible = 0
0348             self.dangerous = False
0349         self.keep = 0.0
0350         self.prev = None
0351         self.next = None
0352         self.prev2 = None
0353         self.next2 = None
0354 
0355     def __lt__(self, other):
0356         """for sorting"""
0357         return self.tile < other.tile
0358 
0359     def __str__(self):
0360         dang = ' dang:%d' % self.dangerous if self.dangerous else ''
0361         return '%s:=%.4f%s' % (self.tile, self.keep, dang)
0362 
0363 
0364 class DiscardCandidates(list):
0365 
0366     """a list of TileAI objects. This class should only hold
0367     AI neutral methods"""
0368 
0369     def __init__(self, player, hand):
0370         list.__init__(self)
0371         self._player = weakref.ref(player)
0372         self._hand = weakref.ref(hand)
0373         if Debug.robotAI:
0374             player.game.debug('DiscardCandidates for hand %s are %s' % (
0375                 hand, hand.tilesInHand))
0376         self.hiddenTiles = [x.exposed for x in hand.tilesInHand]
0377         self.groupCounts = IntDict()
0378                                    # counts for tile groups (sbcdw), exposed
0379                                    # and concealed
0380         for tile in self.hiddenTiles:
0381             self.groupCounts[tile.group] += 1
0382         self.declaredGroupCounts = IntDict()
0383         for tile in sum((x for x in hand.declaredMelds), []):
0384             self.groupCounts[tile.lowerGroup] += 1
0385             self.declaredGroupCounts[tile.lowerGroup] += 1
0386         self.extend(TileAI(self, x) for x in sorted(set(self.hiddenTiles)))
0387         self.link()
0388 
0389     @property
0390     def player(self):
0391         """hide weakref"""
0392         return self._player() if self._player else None
0393 
0394     @property
0395     def hand(self):
0396         """hide weakref"""
0397         return self._hand() if self._hand else None
0398 
0399     def link(self):
0400         """define values for candidate.prev and candidate.next"""
0401         prev = prev2 = None
0402         for this in self:
0403             if this.group in Tile.colors:
0404                 thisValue = this.value
0405                 if prev and prev.group == this.group:
0406                     if prev.value + 1 == thisValue:
0407                         prev.next = this
0408                         this.prev = prev
0409                     if prev.value + 2 == thisValue:
0410                         prev.next2 = this
0411                         this.prev2 = prev
0412                 if prev2 and prev2.group == this.group and prev2.value + 2 == thisValue:
0413                     prev2.next2 = this
0414                     this.prev2 = prev2
0415             prev2 = prev
0416             prev = this
0417         for this in self:
0418             if this.group in Tile.colors:
0419                 # we want every tile to have prev/prev2/next/next2
0420                 # the names do not matter, just occurrence, available etc
0421                 thisValue = this.value
0422                 if not this.prev:
0423                     this.prev = TileAI(self, this.tile.prevForChow)
0424                 if not this.prev2:
0425                     this.prev2 = TileAI(self, this.prev.tile.prevForChow)
0426                 if not this.next:
0427                     this.next = TileAI(self, this.tile.nextForChow)
0428                 if not this.next2:
0429                     this.next2 = TileAI(self, this.next.tile.nextForChow)
0430 
0431     def unlink(self):
0432         """remove links between elements. This helps garbage collection."""
0433         for this in self:
0434             this.prev = None
0435             this.next = None
0436             this.prev2 = None
0437             this.next2 = None
0438 
0439     def best(self):
0440         """return the candidate with the lowest value"""
0441         lowest = min(x.keep for x in self)
0442         candidates = sorted(x for x in self if x.keep == lowest)
0443         result = self.player.game.randomGenerator.choice(
0444             candidates).tile.concealed
0445         if Debug.robotAI:
0446             self.player.game.debug(
0447                 '%s: discards %s out of %s' %
0448                 (self.player, result, ' '.join(str(x) for x in self)))
0449         return result