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

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 datetime
0011 import weakref
0012 
0013 from twisted.spread import pb
0014 from twisted.internet.task import deferLater
0015 from twisted.internet.defer import Deferred, succeed, fail
0016 from util import Duration
0017 from log import logDebug, logException, logWarning
0018 from mi18n import i18nc
0019 from message import Message
0020 from common import Internal, Debug, Options, StrMixin
0021 from common import isAlive
0022 from tilesource import TileSource
0023 from rule import Ruleset
0024 from game import PlayingGame
0025 from query import Query
0026 from move import Move
0027 from animation import animate, animateAndDo
0028 from player import PlayingPlayer
0029 
0030 import intelligence
0031 import altint
0032 
0033 
0034 class Table(StrMixin):
0035 
0036     """defines things common to both ClientTable and ServerTable"""
0037 
0038     def __init__(self, tableid, ruleset, suspendedAt,
0039                  running, playOpen, autoPlay, wantedGame):
0040         self.tableid = tableid
0041         if isinstance(ruleset, Ruleset):
0042             self.ruleset = ruleset
0043         else:
0044             self.ruleset = Ruleset.cached(ruleset)
0045         self.suspendedAt = suspendedAt
0046         self.running = running
0047         self.playOpen = playOpen
0048         self.autoPlay = autoPlay
0049         self.wantedGame = wantedGame
0050 
0051     def status(self):
0052         """a status string"""
0053         result = ''
0054         if self.suspendedAt:
0055             result = i18nc('table status', 'Suspended')
0056             result += ' ' + datetime.datetime.strptime(
0057                 self.suspendedAt,
0058                 '%Y-%m-%dT%H:%M:%S').strftime('%c')
0059         if self.running:
0060             result += ' ' + i18nc('table status', 'Running')
0061         return result or i18nc('table status', 'New')
0062 
0063     def __str__(self):
0064         return 'Table({})'.format(self.tableid)
0065 
0066 
0067 class ClientTable(Table):
0068 
0069     """the table as seen by the client"""
0070     # pylint: disable=too-many-instance-attributes
0071     # pylint: disable=too-many-arguments
0072 
0073     def __init__(self, client, tableid, ruleset, gameid, suspendedAt, running,
0074                  playOpen, autoPlay, wantedGame, playerNames,
0075                  playersOnline, endValues):
0076         Table.__init__(
0077             self,
0078             tableid,
0079             ruleset,
0080             suspendedAt,
0081             running,
0082             playOpen,
0083             autoPlay,
0084             wantedGame)
0085         self.client = client
0086         self.gameid = gameid
0087         self.playerNames = playerNames
0088         self.playersOnline = playersOnline
0089         self.endValues = endValues
0090         self.myRuleset = None  # if set, points to an identical local ruleset
0091         for myRuleset in Ruleset.availableRulesets():
0092             if myRuleset == self.ruleset:
0093                 self.myRuleset = myRuleset
0094                 break
0095         self.chatWindow = None
0096 
0097     def isOnline(self, player):
0098         """did he join the tabled?"""
0099         for idx, name in enumerate(self.playerNames):
0100             if player == name:
0101                 return self.playersOnline[idx]
0102         return False
0103 
0104     def __str__(self):
0105         onlineNames = [x for x in self.playerNames if self.isOnline(x)]
0106         offlineString = ''
0107         offlineNames = [
0108             x for x in self.playerNames if x not in onlineNames
0109             and not x.startswith('Robot')]
0110         if offlineNames:
0111             offlineString = ' offline:' + ','.join(offlineNames)
0112         return '%d(%s %s%s)' % (self.tableid, self.ruleset.name, ','.join(onlineNames), offlineString)
0113 
0114     def gameExistsLocally(self):
0115         """True if the game exists in the data base of the client"""
0116         assert self.gameid
0117         return bool(Query('select 1 from game where id=?', (self.gameid,)).records)
0118 
0119 
0120 class Client(pb.Referenceable):
0121 
0122     """interface to the server. This class only implements the logic,
0123     so we can also use it on the server for robot clients. Compare
0124     with HumanClient(Client)"""
0125 
0126     def __init__(self, name=None):
0127         """name is something like Robot 1 or None for the game server"""
0128         self.name = name
0129         self.game = None
0130         self.__connection = None
0131         self.tables = []
0132         self._table = None
0133         self.tableList = None
0134 
0135     @property
0136     def table(self):
0137         """hide weakref"""
0138         if self._table:
0139             return self._table()
0140         return None
0141 
0142     @table.setter
0143     def table(self, value):
0144         """hide weakref"""
0145         if value is not None:
0146             self._table = weakref.ref(value)
0147 
0148     @property
0149     def connection(self):
0150         """update main window title if needed"""
0151         return self.__connection
0152 
0153     @connection.setter
0154     def connection(self, value):
0155         """update main window title if needed"""
0156         if self.__connection != value:
0157             self.__connection = value
0158             if Internal.scene:
0159                 Internal.scene.mainWindow.updateGUI()
0160 
0161     def _tableById(self, tableid):
0162         """return table with tableid"""
0163         for table in self.tables:
0164             if table.tableid == tableid:
0165                 return table
0166         return None
0167 
0168     def logout(self, unusedResult=None):  # pylint: disable=no-self-use
0169         """virtual"""
0170         return succeed(None)
0171 
0172     def isRobotClient(self):
0173         """avoid using isinstance because that imports too much for the server"""
0174         return bool(self.name)
0175 
0176     @staticmethod
0177     def isHumanClient():
0178         """avoid using isinstance because that imports too much for the server"""
0179         return False
0180 
0181     def isServerClient(self):
0182         """avoid using isinstance because that imports too much for the server"""
0183         return bool(not self.name)
0184 
0185     def remote_newTables(self, tables):
0186         """update table list"""
0187         newTables = [ClientTable(self, *x) for x in tables]
0188         self.tables.extend(newTables)
0189         if Debug.table:
0190             _ = ', '.join(str(ClientTable(self, *x)) for x in tables)
0191             logDebug('%s got new tables:%s' % (self.name, _))
0192 
0193     @staticmethod
0194     def remote_serverRulesets(hashes):
0195         """the server will normally send us hashes of rulesets. If
0196         a hash is not known by us, tell the server so it will send the
0197         full ruleset definition instead of the hash. It would be even better if
0198         the server always only sends the hash and the client then says "I do
0199         not know this ruleset, please send definition", but that would mean
0200         more changes to the client code"""
0201         return [x for x in hashes if not Ruleset.hashIsKnown(x)]
0202 
0203     def tableChanged(self, table):
0204         """update table list"""
0205         newTable = ClientTable(self, *table)
0206         oldTable = self._tableById(newTable.tableid)
0207         if oldTable:
0208             self.tables.remove(oldTable)
0209             self.tables.append(newTable)
0210         return oldTable, newTable
0211 
0212     def remote_tableRemoved(self, tableid, message, *args):  # pylint: disable=unused-argument
0213         """update table list"""
0214         table = self._tableById(tableid)
0215         if table:
0216             self.tables.remove(table)
0217 
0218     def reserveGameId(self, gameid):
0219         """the game server proposes a new game id. We check if it is available
0220         in our local data base - we want to use the same gameid everywhere"""
0221         with Internal.db:
0222             query = Query('insert into game(id,seed) values(?,?)',
0223                           (gameid, self.connection.url), mayFail=True, failSilent=True)
0224             if query.rowcount() != 1:
0225                 return Message.NO
0226         return Message.OK
0227 
0228     @staticmethod
0229     def __findAI(modules, aiName):
0230         """list of all alternative AIs defined in altint.py"""
0231         for module in modules:
0232             for key, value in module.__dict__.items():
0233                 if key == 'AI' + aiName:
0234                     return value
0235         return None
0236 
0237     def __assignIntelligence(self):
0238         """assign intelligence to myself. All players already have default intelligence."""
0239         if self.isHumanClient():
0240             aiClass = self.__findAI([intelligence, altint], Options.AI)
0241             if not aiClass:
0242                 raise Exception('intelligence %s is undefined' % Options.AI)
0243             self.game.myself.intelligence = aiClass(self.game.myself)
0244 
0245     def readyForGameStart(
0246             self, tableid, gameid, wantedGame, playerNames, shouldSave=True, gameClass=None):
0247         """the game server asks us if we are ready. A robot is always ready."""
0248         def disagree(about):
0249             """do not bother to translate this, it should normally not happen"""
0250             self.game.close()
0251             msg = 'The data bases for game %s have different %s' % (
0252                 self.game.seed, about)
0253             logWarning(msg)
0254             raise pb.Error(msg)
0255         if not self.table:
0256             assert not self.isRobotClient()
0257             self.table = self._tableById(tableid)
0258         else:
0259             assert self.isRobotClient()
0260             # robot client instance: self.table is already set
0261         if gameClass is None:
0262             gameClass = PlayingGame
0263         if self.table.suspendedAt:
0264             self.game = gameClass.loadFromDB(gameid, self)
0265             self.game.assignPlayers(playerNames)
0266             if self.isHumanClient():
0267                 if self.game.handctr != self.table.endValues[0]:
0268                     disagree('numbers for played hands: Server:%s, Client:%s' % (
0269                         self.table.endValues[0], self.game.handctr))
0270                 for player in self.game.players:
0271                     if player.balance != self.table.endValues[1][player.wind.char]:
0272                         disagree('balances for wind %s: Server:%s, Client:%s' % (
0273                             player.wind, self.table.endValues[1][player.wind], player.balance))
0274         else:
0275             self.game = gameClass(playerNames, self.table.ruleset,
0276                                   gameid=gameid, wantedGame=wantedGame, client=self,
0277                                   playOpen=self.table.playOpen, autoPlay=self.table.autoPlay)
0278         self.game.shouldSave = shouldSave
0279         self.__assignIntelligence()
0280                                   # intelligence variant is not saved for
0281                                   # suspended games
0282         self.game.prepareHand()
0283         return succeed(Message.OK)
0284 
0285     def readyForHandStart(self, playerNames, rotateWinds):
0286         """the game server asks us if we are ready. A robot is always ready..."""
0287         self.game.assignPlayers(playerNames)
0288         if rotateWinds:
0289             self.game.rotateWinds()
0290         self.game.prepareHand()
0291 
0292     def __delayAnswer(self, result, delay, delayStep):
0293         """try again, may we chow now?"""
0294         if not self.game:
0295             # game has been aborted meanwhile
0296             return result
0297         noClaimCount = 0
0298         delay += delayStep
0299         for move in self.game.lastMoves():
0300             # latest move first
0301             if move.message == Message.Discard:
0302                 break
0303             if move.message == Message.NoClaim and move.notifying:
0304                 noClaimCount += 1
0305                 if noClaimCount == 2:
0306                     if Debug.delayChow:
0307                         self.game.debug('everybody said "I am not interested", so {} claims chow now for {}'.format(
0308                             self.game.myself.name, self.game.lastDiscard.name()))
0309                     return result
0310             elif move.message in (Message.Pung, Message.Kong, Message.MahJongg) and move.notifying:
0311                 if Debug.delayChow:
0312                     self.game.debug('{} said {} so {} suppresses Chow for {}'.format(
0313                         move.player, move.message, self.game.myself, self.game.lastDiscard.name()).replace('  ', ' '))
0314                 return Message.NoClaim
0315         if delay < self.game.ruleset.claimTimeout * 0.95:
0316             # one of those slow humans is still thinking
0317             return deferLater(Internal.reactor, delayStep, self.__delayAnswer, result, delay, delayStep)
0318         if Debug.delayChow:
0319             self.game.debug('{} must chow now for {} because timeout is over'.format(
0320                 self.game.myself.name, self.game.lastDiscard.name()))
0321         return result
0322 
0323     def ask(self, move, answers):
0324         """place the robot AI here.
0325         send answer and one parameter to server"""
0326         delay = 0.0
0327         delayStep = 0.1
0328         myself = self.game.myself
0329         myself.computeSayable(move, answers)
0330         result = myself.intelligence.selectAnswer(answers)
0331         if result[0] == Message.Chow:
0332             if Debug.delayChow:
0333                 self.game.debug('{} waits to see if somebody says Pung or Kong before saying chow for {}'.format(
0334                     self.game.myself.name, self.game.lastDiscard.name()))
0335             return deferLater(Internal.reactor, delayStep, self.__delayAnswer, result, delay, delayStep)
0336         return succeed(result)
0337 
0338     def thatWasMe(self, player):
0339         """return True if player == myself"""
0340         if not self.game:
0341             return False
0342         return player == self.game.myself
0343 
0344     @staticmethod
0345     def __jellyMessage(value):
0346         """the Message classes are not pb.copyable, convert them into their names"""
0347         return Message.OK.name if value is None else Message.jelly(value, value)
0348 
0349     def remote_move(self, playerName, command, *unusedArgs, **kwargs): # pylint: disable=unused-argument
0350         """the server sends us info or a question and always wants us to answer"""
0351         if Internal.scene and not isAlive(Internal.scene):
0352             return fail()
0353         if self.game:
0354             player = self.game.playerByName(playerName)
0355         elif playerName:
0356             player = PlayingPlayer(None, playerName)
0357         else:
0358             player = None
0359         move = Move(player, command, kwargs)
0360         if Debug.traffic:
0361             if self.isHumanClient():
0362                 if self.game:
0363                     self.game.debug('got Move: %s' % move)
0364                 else:
0365                     logDebug('got Move: %s' % move)
0366         if self.game:
0367             if move.token:
0368                 if move.token != self.game.handId.token():
0369                     logException(
0370                         'wrong token: %s, we have %s' %
0371                         (move.token, self.game.handId.token()))
0372         with Duration('Move %s:' % move):
0373             return self.exec_move(move).addCallback(self.__jellyMessage)
0374 
0375     def exec_move(self, move):
0376         """mirror the move of a player as told by the game server"""
0377         message = move.message
0378         if message.needsGame and not self.game:
0379             # server already disconnected, see
0380             # HumanClient.remote_ServerDisconnects
0381             return succeed(Message.OK)
0382         action = message.notifyAction if move.notifying else message.clientAction
0383         game = self.game
0384         if game:
0385             game.moves.append(move)
0386         answer = action(self, move)
0387         if not isinstance(answer, Deferred):
0388             answer = succeed(answer)
0389         if game:
0390             if not move.notifying and move.player and not move.player.scoreMatchesServer(move.score):
0391                 game.close()
0392 # This is an example how to find games where specific situations arise. We prefer games where this
0393 # happens very early for easier reproduction. So set number of rounds to 1 in the ruleset before doing this.
0394 # This example looks for a situation where the single human player may call Chow but one of the
0395 # robot players calls Pung. See https://bugs.kde.org/show_bug.cgi?id=318981
0396 #            if game.nextPlayer() == game.myself:
0397 # I am next
0398 #                if message == Message.Pung and move.notifying:
0399 # somebody claimed a pung
0400 #                    if move.player != game.myself:
0401 # it was not me
0402 #                        if game.handctr == 0 and len(game.moves) < 30:
0403 # early on in the game
0404 #                            game.myself.computeSayable(move, [Message.Chow])
0405 #                            if game.myself.sayable[Message.Chow]:
0406 # I may say Chow
0407 #                                logDebug('FOUND EXAMPLE FOR %s IN %s' % (game.myself,
0408 #                                       game.handId.prompt(withMoveCount=True)))
0409 
0410         if message == Message.Discard:
0411             # do not block here, we want to get the clientDialog
0412             # before the animated tile reaches its end position
0413             animate()
0414             return answer
0415         if message == Message.AskForClaims:
0416             # no need to start an animation. If we did the below standard clause, this is what
0417             # could happen:
0418             # 1. user says Chow
0419             # 2. SelectChow dialog pops up
0420             # 3. previous animation ends, making animate() callback with current answer
0421             # 4. but this answer is Chow, without a selected Chow. This is
0422             # wrongly sent to server
0423             return answer
0424         # return answer only after animation ends. Put answer into
0425         # the Deferred returned by animate().
0426         return animate().addCallback(lambda x: answer)
0427 
0428     def claimed(self, move):
0429         """somebody claimed a discarded tile"""
0430         if Internal.scene:
0431             calledTileItem = Internal.scene.discardBoard.lastDiscarded
0432             calledTile = calledTileItem.tile
0433             Internal.scene.discardBoard.lastDiscarded = None
0434         else:
0435             calledTileItem = None
0436             calledTile = self.game.lastDiscard
0437         self.game.lastDiscard = None
0438         self.game.discardedTiles[calledTile.exposed] -= 1
0439         assert calledTile in move.meld, '%s %s' % (calledTile, move.meld)
0440         hadTiles = move.meld[:]
0441         hadTiles.remove(calledTile)
0442         if not self.thatWasMe(move.player) and not self.game.playOpen:
0443             move.player.showConcealedTiles(hadTiles)
0444         move.player.lastTile = calledTile.exposed
0445         move.player.lastSource = TileSource.LivingWallDiscard
0446         move.exposedMeld = move.player.exposeMeld(
0447             hadTiles,
0448             calledTile=calledTileItem or calledTile)
0449 
0450         if self.thatWasMe(move.player):
0451             if move.message != Message.Kong:
0452                 # we will get a replacement tile first
0453                 return self.myAction(move)
0454         elif self.game.prevActivePlayer == self.game.myself and self.connection:
0455             # even here we ask: if our discard is claimed we need time
0456             # to notice - think 3 robots or network timing differences
0457             return self.ask(move, [Message.OK])
0458         return None
0459 
0460     def myAction(self, move):
0461         """ask myself what I want to do after picking or claiming a tile"""
0462         # only when all animations ended, our handboard gets focus. Otherwise
0463         # we would see a blue focusRect in the handboard even when a tile
0464         # ist still moving from the discardboard to the handboard.
0465         animateAndDo(move.player.getsFocus)
0466         possibleAnswers = [Message.Discard, Message.Kong, Message.MahJongg]
0467         if not move.player.discarded:
0468             possibleAnswers.append(Message.OriginalCall)
0469         return self.ask(move, possibleAnswers)
0470 
0471     def declared(self, move):
0472         """somebody declared something.
0473         By declaring we mean exposing a meld, using only tiles from the hand.
0474         For now we only support Kong: in Classical Chinese it makes no sense
0475         to declare a Pung."""
0476         assert move.message == Message.Kong
0477         if not self.thatWasMe(move.player) and not self.game.playOpen:
0478             move.player.showConcealedTiles(move.source)
0479         move.exposedMeld = move.player.exposeMeld(move.source)
0480         if not self.thatWasMe(move.player):
0481             self.ask(move, [Message.OK])
0482 
0483     def __str__(self):
0484         return self.name