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

0001 # -*- coding: utf-8 -*-
0002 
0003 """
0004 Copyright (C) 2008-2016 Wolfgang Rohdewald <wolfgang@rohdewald.de>
0005 
0006 SPDX-License-Identifier: GPL-2.0
0007 
0008 """
0009 
0010 import datetime
0011 import weakref
0012 import os
0013 import sys
0014 from collections import defaultdict
0015 from functools import total_ordering
0016 
0017 from twisted.internet.defer import succeed
0018 from util import gitHead
0019 from kajcsv import CsvRow
0020 from rand import CountingRandom
0021 from log import logError, logWarning, logException, logDebug, i18n
0022 from common import Internal, IntDict, Debug, Options
0023 from common import StrMixin, Speeds
0024 from wind import Wind, East
0025 from query import Query
0026 from rule import Ruleset
0027 from tile import Tile, elements
0028 from tilesource import TileSource
0029 from sound import Voice
0030 from wall import Wall
0031 from move import Move
0032 from player import Players, Player, PlayingPlayer
0033 from animation import animateAndDo, AnimationSpeed, ParallelAnimationGroup
0034 
0035 if os.name != 'nt':
0036     import resource
0037 
0038 
0039 @total_ordering
0040 class HandId(StrMixin):
0041 
0042     """handle a string representing a hand Id"""
0043 
0044     def __init__(self, game, string=None, stringIdx=0):
0045         self.game = game
0046         self.seed = game.seed
0047         self.roundsFinished = self.rotated = self.notRotated = 0
0048         self.moveCount = 0
0049         if string is None:
0050             self.roundsFinished = game.roundsFinished
0051             self.rotated = game.rotated
0052             self.notRotated = game.notRotated
0053             self.moveCount = len(game.moves)
0054         else:
0055             self.__scanHandId(string, stringIdx)
0056         assert self.rotated < 4, self
0057 
0058     def goto(self):
0059         """advance game to self"""
0060         for _ in range(self.roundsFinished * 4 + self.rotated):
0061             self.game.rotateWinds()
0062         self.game.notRotated = self.notRotated
0063 
0064     def __scanHandId(self, string, stringIdx):
0065         """get the --game option.
0066         stringIdx 0 is the part in front of ..
0067         stringIdx 1 is the part after ..
0068         """
0069         # pylint: disable=too-many-return-statements,too-many-branches
0070         if not string:
0071             return
0072         seed = int(string.split('/')[0])
0073         assert self.seed is None or self.seed == seed, string
0074         self.seed = seed
0075         if '/' not in string:
0076             if stringIdx == 1:
0077                 self.roundsFinished = 100
0078             return
0079         string1 = string.split('/')[1]
0080         if not string1:
0081             logException('--game=%s must specify the wanted round' % string)
0082         parts = string1.split('..')
0083         if len(parts) == 2:
0084             if stringIdx == 0 and parts[0] == '':
0085                 return
0086             if stringIdx == 1 and parts[1] == '':
0087                 self.roundsFinished = 100
0088                 return
0089         handId = parts[min(stringIdx, len(parts) - 1)]
0090         if handId[0].lower() not in 'eswn':
0091             logException('--game=%s must specify the round wind' % string)
0092         handWind = Wind(handId[0])
0093         ruleset = self.game.ruleset
0094         self.roundsFinished = handWind.__index__()
0095         if self.roundsFinished > ruleset.minRounds:
0096             logWarning(
0097                 'Ruleset %s has %d minimum rounds but you want round %d(%s)'
0098                 % (ruleset.name, ruleset.minRounds, self.roundsFinished + 1,
0099                    handWind))
0100             self.roundsFinished = ruleset.minRounds
0101             return
0102         self.rotated = int(handId[1]) - 1
0103         if self.rotated > 3:
0104             logWarning(
0105                 'You want %d rotations, reducing to maximum of 3' %
0106                 self.rotated)
0107             self.rotated = 3
0108             return
0109         for char in handId[2:]:
0110             if char < 'a':
0111                 logWarning('you want %s, changed to a' % char)
0112                 char = 'a'
0113             if char > 'z':
0114                 logWarning('you want %s, changed to z' % char)
0115                 char = 'z'
0116             self.notRotated = self.notRotated * 26 + ord(char) - ord('a') + 1
0117         return
0118 
0119     def prompt(self, withSeed=True, withAI=True, withMoveCount=False):
0120         """
0121         Identifies the hand for window title and scoring table.
0122 
0123         @param withSeed: If set, include the seed used for the
0124         random generator.
0125         @type  withSeed: C{Boolean}
0126         @param withAI:   If set and AI != DefaultAI: include AI name for
0127         human players.
0128         @type  withAI:   C{Boolean}
0129         @param withMoveCount:   If set, include the current count of moves.
0130         @type  withMoveCount:   C{Boolean}
0131         @return:         The prompt.
0132         @rtype:          C{str}
0133         """
0134         aiVariant = ''
0135         if withAI and self.game.belongsToHumanPlayer():
0136             if self.game.myself:
0137                 aiName = self.game.myself.intelligence.name()
0138             else:
0139                 aiName = 'DefaultAI'
0140             if aiName != 'DefaultAI':
0141                 aiVariant = aiName + '/'
0142         num = self.notRotated
0143         assert isinstance(num, int), num
0144         charId = ''
0145         while num:
0146             charId = chr(ord('a') + (num - 1) % 26) + charId
0147             num = (num - 1) // 26
0148         if not charId:
0149             charId = ' ' # align to the most common case
0150         wind = Wind.all4[self.roundsFinished % 4]
0151         if withSeed:
0152             seed = str(self.seed)
0153         else:
0154             seed = ''
0155         delim = '/' if withSeed or withAI else ''
0156         result = '%s%s%s%s%s%s' % (
0157             aiVariant, seed, delim, wind, self.rotated + 1, charId)
0158         if withMoveCount:
0159             result += '/%3d' % self.moveCount
0160         return result
0161 
0162     def token(self):
0163         """server and client use this for checking if they talk about
0164         the same thing"""
0165         return self.prompt(withAI=False)
0166 
0167     def __str__(self):
0168         return self.prompt()
0169 
0170     def __eq__(self, other):
0171         return (other
0172                 and (self.roundsFinished, self.rotated, self.notRotated) ==
0173                 (other.roundsFinished, other.rotated, other.notRotated))
0174 
0175     def __ne__(self, other):
0176         return not self == other # pylint: disable=unneeded-not
0177 
0178     def __lt__(self, other):
0179         return (self.roundsFinished, self.rotated, self.notRotated) < (
0180             other.roundsFinished, other.rotated, other.notRotated)
0181 
0182 
0183 class Game:
0184 
0185     """the game without GUI"""
0186     # pylint: disable=too-many-instance-attributes
0187     playerClass = Player
0188     wallClass = Wall
0189 
0190     def __init__(self, names, ruleset, gameid=None,
0191                  wantedGame=None, client=None):
0192         """a new game instance. May be shown on a field, comes from database
0193         if gameid is set.
0194 
0195         Game.lastDiscard is the tile last discarded by any player. It is
0196         reset to None when a player gets a tile from the living end of the
0197         wall or after he claimed a discard.
0198         """
0199         # pylint: disable=too-many-statements
0200         assert self.__class__ != Game, 'Do not directly instantiate Game'
0201         for wind, name in names:
0202             assert isinstance(wind, Wind), 'Game.__init__ expects Wind objects'
0203             assert isinstance(name, str), 'Game.__init__: name must be string and not {}'.format(type(name))
0204         self.players = Players()
0205         # if we fail later on in init, at least we can still close the program
0206         self.myself = None
0207         # the player using this client instance for talking to the server
0208         self.__shouldSave = False
0209         self._client = None
0210         self.client = client
0211         self.rotated = 0
0212         self.notRotated = 0  # counts hands since last rotation
0213         self.ruleset = None
0214         self.roundsFinished = 0
0215         self._currentHandId = None
0216         self._prevHandId = None
0217         self.wantedGame = wantedGame
0218         self.moves = []
0219         self.gameid = gameid
0220         self.playOpen = False
0221         self.autoPlay = False
0222         self.handctr = 0
0223         self.roundHandCount = 0
0224         self.handDiscardCount = 0
0225         self.divideAt = None
0226         self.__lastDiscard = None  # always uppercase
0227         self.visibleTiles = IntDict()
0228         self.discardedTiles = IntDict(self.visibleTiles)
0229         # tile names are always lowercase
0230         self.dangerousTiles = list()
0231         self.csvTags = []
0232         self.randomGenerator = CountingRandom(self)
0233         self._setHandSeed()
0234         self.activePlayer = None
0235         self.__winner = None
0236         self._setGameId()
0237         self.__useRuleset(ruleset)
0238         # shift rules taken from the OEMC 2005 rules
0239         # 2nd round: S and W shift, E and N shift
0240         self.shiftRules = 'SWEN,SE,WE'
0241         self.wall = self.wallClass(self)
0242         self.assignPlayers(names)
0243         if self.belongsToGameServer():
0244             self.__shufflePlayers()
0245         self._scanGameOption()
0246         for player in self.players:
0247             player.clearHand()
0248 
0249     @property
0250     def shouldSave(self):
0251         """as a property"""
0252         return self.__shouldSave
0253 
0254     @shouldSave.setter
0255     def shouldSave(self, value):
0256         """if activated, save start time"""
0257         if value and not self.__shouldSave:
0258             self.saveStartTime()
0259         self.__shouldSave = value
0260 
0261     @property
0262     def handId(self):
0263         """current position in game"""
0264         result = HandId(self)
0265         if result != self._currentHandId:
0266             self._prevHandId = self._currentHandId
0267             self._currentHandId = result
0268         return result
0269 
0270     @property
0271     def client(self):
0272         """hide weakref"""
0273         return self._client() if self._client else None
0274 
0275     @client.setter
0276     def client(self, value):
0277         """hide weakref"""
0278         if value:
0279             self._client = weakref.ref(value)
0280         else:
0281             self._client = None
0282 
0283     def clearHand(self):
0284         """empty all data"""
0285         if self.moves:
0286             for move in self.moves:
0287                 del move
0288         self.moves = []
0289         for player in self.players:
0290             player.clearHand()
0291         self.__winner = None
0292         self.__activePlayer = None
0293         self.prevActivePlayer = None
0294         self.dangerousTiles = list()
0295         self.discardedTiles.clear()
0296         assert self.visibleTiles.count() == 0
0297 
0298     def _scanGameOption(self):
0299         """this is only done for PlayingGame"""
0300 
0301     @property
0302     def lastDiscard(self):
0303         """hide weakref"""
0304         return self.__lastDiscard
0305 
0306     @lastDiscard.setter
0307     def lastDiscard(self, value):
0308         """hide weakref"""
0309         self.__lastDiscard = value
0310         if value is not None:
0311             assert isinstance(value, Tile), value
0312             if value.isExposed:
0313                 raise Exception('lastDiscard is exposed:%s' % value)
0314 
0315     @property
0316     def winner(self):
0317         """the name of the game server this game is attached to"""
0318         return self.__winner
0319 
0320     @property
0321     def roundWind(self):
0322         """the round wind for Hand"""
0323         return Wind.all[self.roundsFinished % 4]
0324 
0325     @winner.setter
0326     def winner(self, value):
0327         """the name of the game server this game is attached to"""
0328         if self.__winner != value:
0329             if self.__winner:
0330                 self.__winner.invalidateHand()
0331             self.__winner = value
0332             if value:
0333                 value.invalidateHand()
0334 
0335     def addCsvTag(self, tag, forAllPlayers=False):
0336         """tag will be written to tag field in csv row"""
0337         if forAllPlayers or self.belongsToHumanPlayer():
0338             self.csvTags.append('%s/%s' %
0339                                 (tag, self.handId.prompt(withSeed=False)))
0340 
0341     def isFirstHand(self):
0342         """as the name says"""
0343         return self.roundHandCount == 0 and self.roundsFinished == 0
0344 
0345     def _setGameId(self):
0346         """virtual"""
0347         assert not self  # we want it to fail, and quieten pylint
0348 
0349     def close(self):
0350         """log off from the server and return a Deferred"""
0351         self.wall = None
0352         self.lastDiscard = None
0353         if Options.gui:
0354             ParallelAnimationGroup.cancelAll()
0355 
0356     def playerByName(self, playerName):
0357         """return None or the matching player"""
0358         if playerName is None:
0359             return None
0360         for myPlayer in self.players:
0361             if myPlayer.name == playerName:
0362                 return myPlayer
0363         logException('Move references unknown player %s' % playerName)
0364         return None
0365 
0366     def losers(self):
0367         """the 3 or 4 losers: All players without the winner"""
0368         return list(x for x in self.players if x is not self.__winner)
0369 
0370     def belongsToRobotPlayer(self):
0371         """does this game instance belong to a robot player?"""
0372         return self.client and self.client.isRobotClient()
0373 
0374     def belongsToHumanPlayer(self):
0375         """does this game instance belong to a human player?"""
0376         return self.client and self.client.isHumanClient()
0377 
0378     def belongsToGameServer(self):
0379         """does this game instance belong to the game server?"""
0380         return self.client and self.client.isServerClient()
0381 
0382     @staticmethod
0383     def isScoringGame():
0384         """are we scoring a manual game?"""
0385         return False
0386 
0387     def belongsToPlayer(self):
0388         """does this game instance belong to a player
0389         (as opposed to the game server)?"""
0390         return self.belongsToRobotPlayer() or self.belongsToHumanPlayer()
0391 
0392     def assignPlayers(self, playerNames):
0393         """
0394         The server tells us the seating order and player names.
0395 
0396         @param playerNames: A list of 4 tuples. Each tuple holds wind and name.
0397         @type playerNames: The tuple contents must be C{str}
0398         @todo: Can we pass L{Players} instead of that tuple list?
0399         """
0400         if not self.players:
0401             self.players = Players()
0402             for idx in range(4):
0403                 # append each separately: Until they have names, the current length of players
0404                 # is used to assign one of the four walls to the player
0405                 self.players.append(self.playerClass(
0406                     self, playerNames[idx][1]))
0407         for wind, name in playerNames:
0408             self.players.byName(name).wind = wind
0409         if self.client and self.client.name:
0410             self.myself = self.players.byName(self.client.name)
0411         self.sortPlayers()
0412 
0413     def __shufflePlayers(self):
0414         """assign random seats to the players and assign winds"""
0415         self.players.sort(key=lambda x: x.name)
0416         self.randomGenerator.shuffle(self.players)
0417         for player, wind in zip(self.players, Wind.all4):
0418             player.wind = wind
0419 
0420     def __exchangeSeats(self):
0421         """execute seat exchanges according to the rules"""
0422         winds = list(x for x in self.shiftRules.split(',')[(self.roundsFinished - 1) % 4])
0423         players = [self.players[Wind(x)] for x in winds]
0424         pairs = [players[x:x + 2] for x in range(0, len(winds), 2)]
0425         for playerA, playerB in self._mustExchangeSeats(pairs):
0426             playerA.wind, playerB.wind = playerB.wind, playerA.wind
0427 
0428     def _mustExchangeSeats(self, pairs):
0429         """filter: which player pairs should really swap places?"""
0430         # pylint: disable=no-self-use
0431         return pairs
0432 
0433     def sortPlayers(self):
0434         """sort by wind order. Place ourself at bottom (idx=0)"""
0435         self.players.sort(key=lambda x: x.wind)
0436         self.activePlayer = self.players[East]
0437         if Internal.scene:
0438             if self.belongsToHumanPlayer():
0439                 while self.players[0] != self.myself:
0440                     self.players = Players(self.players[1:] + self.players[:1])
0441                 for idx, player in enumerate(self.players):
0442                     player.front = self.wall[idx]
0443                     player.sideText.board = player.front
0444                 # we want names to move simultaneously
0445                 self.players[1].sideText.refreshAll()
0446 
0447     @staticmethod
0448     def _newGameId():
0449         """write a new entry in the game table
0450         and returns the game id of that new entry"""
0451         return Query("insert into game(seed) values(0)").cursor.lastrowid
0452 
0453     def saveStartTime(self):
0454         """save starttime for this game"""
0455         starttime = datetime.datetime.now().replace(microsecond=0).isoformat()
0456         args = list([starttime, self.seed, int(self.autoPlay),
0457                      self.ruleset.rulesetId])
0458         args.extend([p.nameid for p in self.players])
0459         args.append(self.gameid)
0460         Query("update game set starttime=?,seed=?,autoplay=?,"
0461               "ruleset=?,p0=?,p1=?,p2=?,p3=? where id=?", tuple(args))
0462 
0463     def __useRuleset(self, ruleset):
0464         """use a copy of ruleset for this game, reusing an existing copy"""
0465         self.ruleset = ruleset
0466         self.ruleset.load()
0467         if Internal.db:
0468             # only if we have a DB open. False in scoringtest.py
0469             query = Query(
0470                 'select id from ruleset where id>0 and hash=?',
0471                 (self.ruleset.hash,))
0472             if query.records:
0473                 # reuse that ruleset
0474                 self.ruleset.rulesetId = query.records[0][0]
0475             else:
0476                 # generate a new ruleset
0477                 self.ruleset.save()
0478 
0479     @property
0480     def seed(self):  # TODO: move this to PlayingGame
0481         """extract it from wantedGame. Set wantedGame if empty."""
0482         if not self.wantedGame:
0483             self.wantedGame = str(int(self.randomGenerator.random() * 10 ** 9))
0484         return int(self.wantedGame.split('/')[0])
0485 
0486     def _setHandSeed(self):  # TODO: move this to PlayingGame
0487         """set seed to a reproducible value, independent of what happened
0488         in previous hands/rounds.
0489         This makes it easier to reproduce game situations
0490         in later hands without having to exactly replay all previous hands"""
0491         seedFactor = ((self.roundsFinished + 1) * 10000
0492                       + self.rotated * 1000
0493                       + self.notRotated * 100)
0494         self.randomGenerator.seed(self.seed * seedFactor)
0495 
0496     def prepareHand(self):
0497         """prepare a game hand"""
0498         self.clearHand()
0499         if self.finished():
0500             if Options.rounds:
0501                 self.close().addCallback(Internal.mainWindow.close)
0502             else:
0503                 self.close()
0504 
0505     def initHand(self):
0506         """directly before starting"""
0507         self.dangerousTiles = list()
0508         self.discardedTiles.clear()
0509         assert self.visibleTiles.count() == 0
0510         if Internal.scene:
0511             # TODO: why not self.scene?
0512             Internal.scene.prepareHand()
0513         self._setHandSeed()
0514 
0515     def saveHand(self):
0516         """save hand to database,
0517         update score table and balance in status line"""
0518         self.__payHand()
0519         self._saveScores()
0520         self.handctr += 1
0521         self.notRotated += 1
0522         self.roundHandCount += 1
0523         self.handDiscardCount = 0
0524 
0525     def _saveScores(self):
0526         """save computed values to database,
0527         update score table and balance in status line"""
0528         scoretime = datetime.datetime.now().replace(microsecond=0).isoformat()
0529         logMessage = ''
0530         for player in self.players:
0531             if player.hand:
0532                 manualrules = '||'.join(x.rule.name
0533                                         for x in player.hand.usedRules)
0534             else:
0535                 manualrules = i18n('Score computed manually')
0536             Query(
0537                 "INSERT INTO SCORE "
0538                 "(game,hand,data,manualrules,player,scoretime,won,prevailing,"
0539                 "wind,points,payments, balance,rotated,notrotated) "
0540                 "VALUES(%d,%d,?,?,%d,'%s',%d,'%s','%s',%d,%d,%d,%d,%d)" %
0541                 (self.gameid, self.handctr, player.nameid,
0542                  scoretime, int(player == self.__winner),
0543                  self.roundWind.char, player.wind,
0544                  player.handTotal, player.payment, player.balance,
0545                  self.rotated, self.notRotated),
0546                 (player.hand.string, manualrules))
0547             logMessage += '{player:<12} {hand:>4} {total:>5} {won} | '.format(
0548                 player=str(player)[:12], hand=player.handTotal,
0549                 total=player.balance,
0550                 won='WON' if player == self.winner else '   ')
0551             for usedRule in player.hand.usedRules:
0552                 rule = usedRule.rule
0553                 if rule.score.limits:
0554                     self.addCsvTag(rule.name.replace(' ', ''))
0555         if Debug.scores:
0556             self.debug(logMessage)
0557 
0558     def maybeRotateWinds(self):
0559         """rules which make winds rotate"""
0560         result = [x for x in self.ruleset.filterRules('rotate') if x.rotate(self)]
0561         if result:
0562             if Debug.explain:
0563                 if not self.belongsToRobotPlayer():
0564                     self.debug(','.join(x.name for x in result), prevHandId=True)
0565             self.rotateWinds()
0566         return bool(result)
0567 
0568     def rotateWinds(self):
0569         """rotate winds, exchange seats. If finished, update database"""
0570         self.rotated += 1
0571         self.notRotated = 0
0572         if self.rotated == 4:
0573             self.roundsFinished += 1
0574             self.rotated = 0
0575             self.roundHandCount = 0
0576         if self.finished():
0577             endtime = datetime.datetime.now().replace(
0578                 microsecond=0).isoformat()
0579             with Internal.db as transaction:
0580                 transaction.execute(
0581                     'UPDATE game set endtime = "%s" where id = %d' %
0582                     (endtime, self.gameid))
0583         elif not self.belongsToPlayer():
0584             # the game server already told us the new placement and winds
0585             winds = [player.wind for player in self.players]
0586             winds = winds[3:] + winds[0:3]
0587             for idx, newWind in enumerate(winds):
0588                 self.players[idx].wind = newWind
0589             if self.roundsFinished % 4 and self.rotated == 0:
0590                 # exchange seats between rounds
0591                 self.__exchangeSeats()
0592             if Internal.scene:
0593                 with AnimationSpeed(Speeds.windMarker):
0594                     self.wall.showWindMarkers()
0595 
0596     def debug(self, msg, btIndent=None, prevHandId=False):
0597         """
0598         Log a debug message.
0599 
0600         @param msg: The message.
0601         @type msg: A string.
0602         @param btIndent: If given, message is indented by
0603         depth(backtrace)-btIndent
0604         @type btIndent: C{int}
0605         @param prevHandId: If True, do not use current handId but previous
0606         @type prevHandId: C{bool}
0607         """
0608         if self.belongsToRobotPlayer():
0609             prefix = 'R'
0610         elif self.belongsToHumanPlayer():
0611             prefix = 'C'
0612         elif self.belongsToGameServer():
0613             prefix = 'S'
0614         else:
0615             logDebug(msg, btIndent=btIndent)
0616             return
0617         handId = self._prevHandId if prevHandId else self.handId
0618         handId = handId.prompt(withMoveCount=True)
0619         logDebug(
0620             '%s%s: %s' % (prefix, handId, msg),
0621             withGamePrefix=False,
0622             btIndent=btIndent)
0623 
0624     @staticmethod
0625     def __getName(playerid):
0626         """get name for playerid
0627         """
0628         try:
0629             return Players.allNames[playerid]
0630         except KeyError:
0631             return i18n('Player %1 not known', playerid)
0632 
0633     @classmethod
0634     def loadFromDB(cls, gameid, client=None):
0635         """load game by game id and return a new Game instance"""
0636         Internal.logPrefix = 'S' if Internal.isServer else 'C'
0637         records = Query(
0638             "select p0,p1,p2,p3,ruleset,seed from game where id = ?",
0639             (gameid,)).records
0640         if not records:
0641             return None
0642         qGameRecord = records[0]
0643         rulesetId = qGameRecord[4] or 1
0644         ruleset = Ruleset.cached(rulesetId)
0645         Players.load()  # we want to make sure we have the current definitions
0646         records = Query(
0647             "select hand,rotated from score where game=? and hand="
0648             "(select max(hand) from score where game=?)",
0649             (gameid, gameid)).records
0650         if records:
0651             qLastHandRecord = records[0]
0652         else:
0653             qLastHandRecord = tuple([0, 0])
0654         qScoreRecords = Query(
0655             "select player, wind, balance, won, prevailing from score "
0656             "where game=? and hand=?",
0657             (gameid, qLastHandRecord[0])).records
0658         if not qScoreRecords:
0659             # this should normally not happen
0660             qScoreRecords = list(
0661                 tuple([qGameRecord[wind], wind.char, 0, False, East.char])
0662                 for wind in Wind.all4)
0663         if len(qScoreRecords) != 4:
0664             logError('game %d inconsistent: There should be exactly '
0665                      '4 score records for the last hand' % gameid)
0666 
0667         # after loading SQL, prepare values.
0668 
0669         # default value. If the server saved a score entry but our client
0670         # did not, we get no record here. Should we try to fix this or
0671         # exclude such a game from the list of resumable games?
0672         if len({x[4] for x in qScoreRecords}) != 1:
0673             logError('game %d inconsistent: All score records for the same '
0674                      'hand must have the same prevailing wind' % gameid)
0675 
0676         players = [tuple([Wind(x[1]), Game.__getName(x[0])]) for x in qScoreRecords]
0677 
0678         # create the game instance.
0679         game = cls(players, ruleset, gameid=gameid, client=client,
0680                    wantedGame=qGameRecord[5])
0681         game.handctr, game.rotated = qLastHandRecord
0682 
0683         for record in qScoreRecords:
0684             playerid = record[0]
0685             player = game.players.byId(playerid)
0686             if not player:
0687                 logError(
0688                     'game %d inconsistent: player %d missing in game table' %
0689                     (gameid, playerid))
0690             else:
0691                 player.getsPayment(record[2])
0692             if record[3]:
0693                 game.winner = player
0694         game.roundsFinished = Wind(qScoreRecords[0][4]).__index__()
0695         game.handctr += 1
0696         game.notRotated += 1
0697         game.maybeRotateWinds()
0698         game.sortPlayers()
0699         with AnimationSpeed(Speeds.windMarker):
0700             animateAndDo(game.wall.decorate4)
0701         return game
0702 
0703     def finished(self):
0704         """The game is over after minRounds completed rounds. Also,
0705         check if we reached the second handId defined by --game.
0706         If we did, the game is over too"""
0707         last = HandId(self, self.wantedGame, 1)
0708         if self.handId > last:
0709             return True
0710         if Options.rounds:
0711             return self.roundsFinished >= Options.rounds
0712         if self.ruleset:
0713             # while initialising Game, ruleset might be None
0714             return self.roundsFinished >= self.ruleset.minRounds
0715         return None
0716 
0717     def __payHand(self):
0718         """pay the scores"""
0719         # pylint: disable=too-many-branches
0720         # too many branches
0721         winner = self.__winner
0722         if winner:
0723             winner.wonCount += 1
0724             guilty = winner.usedDangerousFrom
0725             if guilty:
0726                 payAction = self.ruleset.findUniqueOption('payforall')
0727             if guilty and payAction:
0728                 if Debug.dangerousGame:
0729                     self.debug('%s: winner %s. %s pays for all' %
0730                                (self.handId, winner, guilty))
0731                 guilty.hand.usedRules.append((payAction, None))
0732                 score = winner.handTotal
0733                 score = score * 6 if winner.wind == East else score * 4
0734                 guilty.getsPayment(-score)
0735                 winner.getsPayment(score)
0736                 return
0737 
0738         for player1 in self.players:
0739             if Debug.explain:
0740                 if not self.belongsToRobotPlayer():
0741                     self.debug('%s: %s' % (player1, player1.hand.string))
0742                     for line in player1.hand.explain():
0743                         self.debug('   %s' % (line))
0744             for player2 in self.players:
0745                 if id(player1) != id(player2):
0746                     if East in (player1.wind, player2.wind):
0747                         efactor = 2
0748                     else:
0749                         efactor = 1
0750                     if player2 != winner:
0751                         player1.getsPayment(player1.handTotal * efactor)
0752                     if player1 != winner:
0753                         player1.getsPayment(-player2.handTotal * efactor)
0754 
0755     def lastMoves(self, only=None, without=None, withoutNotifications=False):
0756         """filters and yields the moves in reversed order"""
0757         for idx in range(len(self.moves) - 1, -1, -1):
0758             move = self.moves[idx]
0759             if withoutNotifications and move.notifying:
0760                 continue
0761             if only:
0762                 if move.message in only:
0763                     yield move
0764             elif without:
0765                 if move.message not in without:
0766                     yield move
0767             else:
0768                 yield move
0769 
0770     def throwDices(self):
0771         """set random living and kongBox
0772         sets divideAt: an index for the wall break"""
0773         breakWall = self.randomGenerator.randrange(4)
0774         sideLength = len(self.wall.tiles) // 4
0775         # use the sum of four dices to find the divide
0776         self.divideAt = breakWall * sideLength + \
0777             sum(self.randomGenerator.randrange(1, 7) for idx in range(4))
0778         if self.divideAt % 2 == 1:
0779             self.divideAt -= 1
0780         self.divideAt %= len(self.wall.tiles)
0781 
0782 
0783 class PlayingGame(Game):
0784 
0785     """this game is played using the computer"""
0786     # pylint: disable=too-many-arguments,too-many-public-methods
0787     # pylint: disable=too-many-instance-attributes
0788     playerClass = PlayingPlayer
0789 
0790     def __init__(self, names, ruleset, gameid=None, wantedGame=None,
0791                  client=None, playOpen=False, autoPlay=False):
0792         """a new game instance, comes from database if gameid is set"""
0793         self.__activePlayer = None
0794         self.prevActivePlayer = None
0795         self.defaultNameBrush = None
0796         Game.__init__(self, names, ruleset,
0797                       gameid, wantedGame=wantedGame, client=client)
0798         self.players['E'].lastSource = TileSource.East14th
0799         self.playOpen = playOpen
0800         self.autoPlay = autoPlay
0801         myself = self.myself
0802         if self.belongsToHumanPlayer() and myself:
0803             myself.voice = Voice.locate(myself.name)
0804             if myself.voice:
0805                 if Debug.sound:
0806                     logDebug('myself %s gets voice %s' % (
0807                         myself.name, myself.voice))
0808             else:
0809                 if Debug.sound:
0810                     logDebug('myself %s gets no voice' % (myself.name))
0811 
0812     def writeCsv(self):
0813         """write game summary to Options.csv"""
0814         if self.finished() and Options.csv:
0815             gameWinner = max(self.players, key=lambda x: x.balance)
0816             if Debug.process and os.name != 'nt':
0817                 self.csvTags.append('MEM:%s' % resource.getrusage(
0818                     resource.RUSAGE_SELF).ru_maxrss)
0819             if Options.rounds:
0820                 self.csvTags.append('ROUNDS:%s' % Options.rounds)
0821             _ = CsvRow.fields
0822             row = [''] * CsvRow.fields.PLAYERS
0823             row[_.GAME] = str(self.seed)
0824             row[_.RULESET] = self.ruleset.name
0825             row[_.AI] = Options.AI
0826             row[_.COMMIT] = gitHead()
0827             row[_.PY_VERSION] = '{}.{}'.format(*sys.version_info[:2])
0828             row[_.TAGS] = ','.join(self.csvTags)
0829             for player in sorted(self.players, key=lambda x: x.name):
0830                 row.append(player.name)
0831                 row.append(player.balance)
0832                 row.append(player.wonCount)
0833                 row.append(1 if player == gameWinner else 0)
0834             CsvRow(row).write()
0835 
0836     def close(self):
0837         """log off from the server and return a Deferred"""
0838         Game.close(self)
0839         self.writeCsv()
0840         Internal.autoPlay = False  # do that only for the first game
0841         if self.client:
0842             client = self.client
0843             self.client = None
0844             result = client.logout()
0845         else:
0846             result = succeed(None)
0847         return result
0848 
0849     def _setGameId(self):
0850         """do nothing, we already went through the game id reservation"""
0851 
0852     @property
0853     def activePlayer(self):
0854         """the turn is on this player"""
0855         return self.__activePlayer
0856 
0857     @activePlayer.setter
0858     def activePlayer(self, player):
0859         """the turn is on this player"""
0860         if self.__activePlayer != player:
0861             self.prevActivePlayer = self.__activePlayer
0862             if self.prevActivePlayer:
0863                 self.prevActivePlayer.hidePopup()
0864             self.__activePlayer = player
0865             if Internal.scene:  # mark the name of the active player in blue
0866                 for _ in self.players:
0867                     _.colorizeName()
0868 
0869     def prepareHand(self):
0870         """prepares the next hand"""
0871         Game.prepareHand(self)
0872         if not self.finished():
0873             self.sortPlayers()
0874             self.hidePopups()
0875             self._setHandSeed()
0876             self.wall.build(shuffleFirst=True)
0877 
0878     def hidePopups(self):
0879         """hide all popup messages"""
0880         for player in self.players:
0881             player.hidePopup()
0882 
0883     def saveStartTime(self):
0884         """write a new entry in the game table with the selected players"""
0885         if not self.gameid:
0886             # in server.__prepareNewGame, gameid is None here
0887             return
0888         records = Query("select seed from game where id=?", (
0889             self.gameid,)).records
0890         assert records, 'self.gameid: %s' % self.gameid
0891         seed = records[0][0]
0892 
0893         if not Internal.isServer and self.client:
0894             host = self.client.connection.url
0895         else:
0896             host = None
0897 
0898         if seed in ('proposed', host):
0899             # we reserved the game id by writing a record with seed == host
0900             Game.saveStartTime(self)
0901 
0902     def _saveScores(self):
0903         """save computed values to database, update score table
0904         and balance in status line"""
0905         if self.shouldSave:
0906             if self.belongsToRobotPlayer():
0907                 assert False, 'shouldSave must not be True for robot player'
0908             Game._saveScores(self)
0909 
0910     def nextPlayer(self, current=None):
0911         """return the player after current or after activePlayer"""
0912         if not current:
0913             current = self.activePlayer
0914         pIdx = self.players.index(current)
0915         return self.players[(pIdx + 1) % 4]
0916 
0917     def nextTurn(self):
0918         """move activePlayer"""
0919         self.activePlayer = self.nextPlayer()
0920 
0921     def __concealedTileName(self, tileName):
0922         """tileName has been discarded, by which name did we know it?"""
0923         player = self.activePlayer
0924         if self.myself and player != self.myself and not self.playOpen:
0925             # we are human and server tells us another player discarded
0926             # a tile. In our game instance, tiles in handBoards of other
0927             # players are unknown
0928             player.makeTileKnown(tileName)
0929             result = Tile.unknown
0930         else:
0931             result = tileName
0932         if tileName not in player.concealedTiles:
0933             raise Exception('I am %s. Player %s is told to show discard '
0934                             'of tile %s but does not have it, he has %s' %
0935                             (self.myself.name if self.myself else 'None',
0936                              player.name, result, player.concealedTiles))
0937         return result
0938 
0939     def hasDiscarded(self, player, tileName):
0940         """discards a tile from a player board"""
0941         # pylint: disable=too-many-branches
0942         # too many branches
0943         assert isinstance(tileName, Tile)
0944         if player != self.activePlayer:
0945             raise Exception('Player %s discards but %s is active' % (
0946                 player, self.activePlayer))
0947         self.discardedTiles[tileName.exposed] += 1
0948         player.discarded.append(tileName)
0949         self.__concealedTileName(tileName)
0950         # the above has side effect, needs to be called
0951         if Internal.scene:
0952             player.handBoard.discard(tileName)
0953         self.lastDiscard = Tile(tileName)
0954         player.removeTile(self.lastDiscard)
0955         if any(tileName.exposed in x[0] for x in self.dangerousTiles):
0956             self.computeDangerous()
0957         else:
0958             self._endWallDangerous()
0959         self.handDiscardCount += 1
0960 
0961     def saveHand(self):
0962         """server told us to save this hand"""
0963         for player in self.players:
0964             handWonMatches = player.hand.won == (player == self.winner)
0965             assert handWonMatches, 'hand.won:%s winner:%s' % (
0966                 player.hand.won, player == self.winner)
0967         Game.saveHand(self)
0968 
0969     def _mustExchangeSeats(self, pairs):
0970         """filter: which player pairs should really swap places?"""
0971         # if we are a client in a remote game, the server swaps and tells
0972         # us the new places
0973         return [] if self.belongsToPlayer() else pairs
0974 
0975     def _scanGameOption(self):
0976         """scan the --game option and go to start of wanted hand"""
0977         if '/' in self.wantedGame:
0978             first, last = (HandId(self, self.wantedGame, x) for x in (0, 1))
0979             if first > last:
0980                 raise UserWarning('{}..{} is a negative range'.format(
0981                     first, last))
0982             HandId(self, self.wantedGame).goto()
0983 
0984     def assignVoices(self):
0985         """now we have all remote user voices"""
0986         assert self.belongsToHumanPlayer()
0987         available = Voice.availableVoices()[:]
0988         # available is without transferred human voices
0989         for player in self.players:
0990             if player.voice and player.voice.oggFiles():
0991                 # remote human player sent her voice, or we are human
0992                 # and have a voice
0993                 if Debug.sound and player != self.myself:
0994                     logDebug('%s got voice from opponent: %s' % (
0995                         player.name, player.voice))
0996             else:
0997                 player.voice = Voice.locate(player.name)
0998                 if player.voice:
0999                     if Debug.sound:
1000                         logDebug('%s has own local voice %s' % (
1001                             player.name, player.voice))
1002             if player.voice:
1003                 for voice in Voice.availableVoices():
1004                     if (voice in available
1005                             and voice.md5sum == player.voice.md5sum):
1006                         # if the local voice is also predefined,
1007                         # make sure we do not use both
1008                         available.remove(voice)
1009         # for the other players use predefined voices in preferred language.
1010         # Only if we do not have enough predefined voices, look again in
1011         # locally defined voices
1012         predefined = [x for x in available if x.language() != 'local']
1013         predefined.extend(available)
1014         for player in self.players:
1015             if player.voice is None and predefined:
1016                 player.voice = predefined.pop(0)
1017                 if Debug.sound:
1018                     logDebug(
1019                         '%s gets one of the still available voices %s' % (
1020                             player.name, player.voice))
1021 
1022     def dangerousFor(self, forPlayer, tile):
1023         """return a list of explaining texts if discarding tile
1024         would be Dangerous game for forPlayer. One text for each
1025         reason - there might be more than one"""
1026         assert isinstance(tile, Tile), tile
1027         tile = tile.exposed
1028         result = []
1029         for dang, txt in self.dangerousTiles:
1030             if tile in dang:
1031                 result.append(txt)
1032         for player in forPlayer.others():
1033             for dang, txt in player.dangerousTiles:
1034                 if tile in dang:
1035                     result.append(txt)
1036         return result
1037 
1038     def computeDangerous(self, playerChanged=None):
1039         """recompute gamewide dangerous tiles. Either for playerChanged or
1040         for all players"""
1041         self.dangerousTiles = list()
1042         if playerChanged:
1043             playerChanged.findDangerousTiles()
1044         else:
1045             for player in self.players:
1046                 player.findDangerousTiles()
1047         self._endWallDangerous()
1048 
1049     def _endWallDangerous(self):
1050         """if end of living wall is reached, declare all invisible tiles
1051         as dangerous"""
1052         if len(self.wall.living) <= 5:
1053             allTiles = [x for x in defaultdict.keys(elements.occurrence)
1054                         if not x.isBonus]
1055             for tile in allTiles:
1056                 assert isinstance(tile, Tile), tile
1057             # see https://www.logilab.org/ticket/23986
1058             invisibleTiles = {x for x in allTiles if x not in self.visibleTiles}
1059             msg = i18n('Short living wall: Tile is invisible, hence dangerous')
1060             self.dangerousTiles = [x for x in self.dangerousTiles if x[1] != msg]
1061             self.dangerousTiles.append((invisibleTiles, msg))
1062 
1063     def appendMove(self, player, command, kwargs):
1064         """append a Move object to self.moves"""
1065         self.moves.append(Move(player, command, kwargs))