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