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