File indexing completed on 2024-03-24 04:04:36
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 0012 from qt import QPointF, QRectF, QDialogButtonBox 0013 from qt import QGraphicsRectItem, QGraphicsSimpleTextItem 0014 from qt import QPushButton, QMessageBox, QComboBox 0015 0016 0017 from common import Internal, isAlive, Debug 0018 from wind import Wind 0019 from tilesource import TileSource 0020 from animation import animate 0021 from log import logError, logDebug, logWarning, i18n 0022 from query import Query 0023 from uitile import UITile 0024 from board import WindLabel, Board 0025 from game import Game 0026 from games import Games 0027 from hand import Hand 0028 from handboard import HandBoard, TileAttr 0029 from player import Player, Players 0030 from visible import VisiblePlayer 0031 from tables import SelectRuleset 0032 from uiwall import UIWall, SideText 0033 from guiutil import decorateWindow, BlockSignals, rotateCenter, sceneRotation 0034 from mi18n import i18nc 0035 0036 0037 class SwapDialog(QMessageBox): 0038 0039 """ask the user if two players should change seats""" 0040 0041 def __init__(self, swappers): 0042 QMessageBox.__init__(self) 0043 decorateWindow(self, i18nc("@title:window", "Swap Seats")) 0044 self.setText( 0045 i18n("By the rules, %1 and %2 should now exchange their seats. ", 0046 swappers[0].name, swappers[1].name)) 0047 self.yesAnswer = QPushButton(i18n("&Exchange")) 0048 self.addButton(self.yesAnswer, QMessageBox.YesRole) 0049 self.noAnswer = QPushButton(i18n("&Keep seat")) 0050 self.addButton(self.noAnswer, QMessageBox.NoRole) 0051 0052 0053 class SelectPlayers(SelectRuleset): 0054 0055 """a dialog for selecting four players. Used only for scoring game.""" 0056 0057 def __init__(self): 0058 SelectRuleset.__init__(self) 0059 Players.load() 0060 decorateWindow(self, i18nc("@title:window", "Select four players")) 0061 self.names = None 0062 self.nameWidgets = [] 0063 for idx, wind in enumerate(Wind.all4): 0064 cbName = QComboBox() 0065 cbName.manualSelect = False 0066 # increase width, we want to see the full window title 0067 cbName.setMinimumWidth(350) # is this good for all platforms? 0068 cbName.addItems(list(Players.humanNames.values())) 0069 self.grid.addWidget(cbName, idx + 1, 1) 0070 self.nameWidgets.append(cbName) 0071 self.grid.addWidget(WindLabel(wind), idx + 1, 0) 0072 cbName.currentIndexChanged.connect(self.slotValidate) 0073 0074 query = Query( 0075 "select p0,p1,p2,p3 from game where seed is null and game.id = (select max(id) from game)") 0076 if query.records: 0077 with BlockSignals(self.nameWidgets): 0078 for cbName, playerId in zip(self.nameWidgets, query.records[0]): 0079 try: 0080 playerName = Players.humanNames[playerId] 0081 playerIdx = cbName.findText(playerName) 0082 if playerIdx >= 0: 0083 cbName.setCurrentIndex(playerIdx) 0084 except KeyError: 0085 logError('database is inconsistent: player with id %d is in game but not in player' 0086 % playerId) 0087 self.slotValidate() 0088 0089 def showEvent(self, unusedEvent): 0090 """start with player 0""" 0091 self.nameWidgets[0].setFocus() 0092 0093 def __selectedNames(self): 0094 """A set with the currently selected names""" 0095 return {cbName.currentText() for cbName in self.nameWidgets} 0096 0097 def slotValidate(self): 0098 """try to find 4 different players and update status of the Ok button""" 0099 changedCombo = self.sender() 0100 if not isinstance(changedCombo, QComboBox): 0101 changedCombo = self.nameWidgets[0] 0102 changedCombo.manualSelect = True 0103 allNames = set(Players.humanNames.values()) 0104 unusedNames = allNames - self.__selectedNames() 0105 with BlockSignals(self.nameWidgets): 0106 used = {x.currentText() for x in self.nameWidgets if x.manualSelect} 0107 for combo in self.nameWidgets: 0108 if not combo.manualSelect: 0109 if combo.currentText() in used: 0110 comboName = unusedNames.pop() 0111 combo.clear() 0112 combo.addItems([comboName]) 0113 used.add(combo.currentText()) 0114 for combo in self.nameWidgets: 0115 comboName = combo.currentText() 0116 combo.clear() 0117 combo.addItems([comboName]) 0118 combo.addItems(sorted( 0119 allNames - self.__selectedNames() - {comboName})) 0120 combo.setCurrentIndex(0) 0121 self.buttonBox.button(QDialogButtonBox.Ok).setEnabled( 0122 len(self.__selectedNames()) == 4) 0123 self.names = [cbName.currentText() for cbName in self.nameWidgets] 0124 0125 0126 class ScoringTileAttr(TileAttr): 0127 0128 """Tile appearance is different in a ScoringHandBoard""" 0129 0130 def setDark(self): 0131 """should the tile appear darker?""" 0132 return self.yoffset or self.tile.isConcealed 0133 0134 def setFocusable(self, hand, meld, idx): 0135 """in a scoring handboard, only the first tile of a meld is focusable""" 0136 return idx == 0 0137 0138 0139 class ScoringHandBoard(HandBoard): 0140 0141 """a board showing the tiles a player holds""" 0142 # pylint: disable=too-many-public-methods,too-many-instance-attributes 0143 tileAttrClass = ScoringTileAttr 0144 0145 def __init__(self, player): 0146 self.__moveHelper = None 0147 self.uiMelds = [] 0148 HandBoard.__init__(self, player) 0149 0150 def meldVariants(self, tile, lowerHalf): 0151 """Kong might have variants""" 0152 result = [] 0153 meld = self.uiMeldWithTile(tile).meld # pylint: disable=no-member 0154 result.append(meld.concealed if lowerHalf else meld.exposed) 0155 if len(meld) == 4: 0156 if lowerHalf: 0157 result = [meld.declared] 0158 else: 0159 result.append(meld.exposedClaimed) 0160 return result 0161 0162 def mapMouseTile(self, uiTile): 0163 """map the pressed tile to the wanted tile. For melds, this would 0164 be the first tile no matter which one is pressed""" 0165 return self.uiMeldWithTile(uiTile)[0] 0166 0167 def uiMeldWithTile(self, uiTile): 0168 """return the meld with uiTile""" 0169 for myMeld in self.uiMelds: 0170 if uiTile in myMeld: 0171 return myMeld 0172 return None 0173 0174 def findUIMeld(self, meld): 0175 """find the first UIMeld matching the logical meld""" 0176 for result in self.uiMelds: 0177 if result.meld == meld: 0178 return result 0179 return None 0180 0181 def assignUITiles(self, uiTile, meld): # pylint: disable=unused-argument 0182 """generate a UIMeld. First uiTile is given, the rest should be as defined by meld""" 0183 assert isinstance(uiTile, UITile), uiTile 0184 return self.uiMeldWithTile(uiTile) 0185 0186 def sync(self, adding=None): # pylint: disable=unused-argument 0187 """place all tiles in ScoringHandBoard""" 0188 self.placeTiles(sum(self.uiMelds, [])) 0189 0190 def deselect(self, meld): 0191 """remove meld from old board""" 0192 for idx, uiMeld in enumerate(self.uiMelds): 0193 if all(id(meld[x]) == id(uiMeld[x]) for x in range(len(meld))): 0194 del self.uiMelds[ 0195 idx] # do not use uiMelds.remove: If we have 2 0196 break # identical melds, it removes the wrong one 0197 self.player.removeMeld(meld) # uiMeld must already be deleted 0198 0199 def dragMoveEvent(self, event): 0200 """allow dropping of uiTile from ourself only to other state (open/concealed)""" 0201 uiTile = event.mimeData().uiTile 0202 localY = self.mapFromScene(QPointF(event.scenePos())).y() 0203 centerY = self.rect().height() / 2.0 0204 newLowerHalf = localY >= centerY 0205 noMansLand = centerY / 6 0206 if -noMansLand < localY - centerY < noMansLand and not uiTile.isBonus: 0207 doAccept = False 0208 elif uiTile.board != self: 0209 doAccept = True 0210 elif uiTile.isBonus: 0211 doAccept = False 0212 else: 0213 oldLowerHalf = uiTile.board.isHandBoard and uiTile in uiTile.board.lowerHalfTiles( 0214 ) 0215 doAccept = oldLowerHalf != newLowerHalf 0216 event.setAccepted(doAccept) 0217 0218 def dropEvent(self, event): 0219 """drop into this handboard""" 0220 uiTile = event.mimeData().uiTile 0221 lowerHalf = self.mapFromScene( 0222 QPointF(event.scenePos())).y() >= self.rect().height() / 2.0 0223 if self.dropTile(uiTile, lowerHalf): 0224 event.accept() 0225 else: 0226 event.ignore() 0227 self._noPen() 0228 0229 def dropTile(self, uiTile, lowerHalf): 0230 """drop uiTile into lower or upper half of our hand""" 0231 senderBoard = uiTile.board 0232 newMeld = senderBoard.chooseVariant(uiTile, lowerHalf) 0233 if not newMeld: 0234 return False 0235 uiMeld = senderBoard.assignUITiles(uiTile, newMeld) 0236 for uitile, tile in zip(uiMeld, newMeld): 0237 uitile.tile = tile 0238 return self.dropMeld(uiMeld) 0239 0240 def dropMeld(self, uiMeld): 0241 """drop uiMeld into our hand""" 0242 senderBoard = uiMeld[0].board 0243 senderBoard.deselect(uiMeld) 0244 self.uiMelds.append(uiMeld) 0245 self.player.addMeld(uiMeld.meld) 0246 self.sync() 0247 self.hasLogicalFocus = senderBoard == self or not senderBoard.uiTiles 0248 self.checkTiles() 0249 senderBoard.autoSelectTile() 0250 senderBoard.checkTiles() 0251 if senderBoard is not self and senderBoard.isHandBoard: 0252 Internal.scene.handSelectorChanged(senderBoard) 0253 Internal.scene.handSelectorChanged(self) 0254 animate() 0255 self.checkTiles() 0256 return True 0257 0258 def focusRectWidth(self): 0259 """how many tiles are in focus rect? We want to focus 0260 the entire meld""" 0261 meld = self.uiMeldWithTile(self.focusTile) 0262 return len(meld) if meld else 1 0263 0264 def addUITile(self, uiTile): 0265 Board.addUITile(self, uiTile) 0266 self.showMoveHelper() 0267 0268 def removeUITile(self, uiTile): 0269 Board.removeUITile(self, uiTile) 0270 self.showMoveHelper() 0271 0272 def showMoveHelper(self, visible=None): 0273 """show help text In empty HandBoards""" 0274 if visible is None: 0275 visible = not self.uiTiles 0276 if self.__moveHelper and not isAlive(self.__moveHelper): 0277 return 0278 if visible: 0279 if not self.__moveHelper: 0280 splitter = QGraphicsRectItem(self) 0281 hbCenter = self.rect().center() 0282 splitter.setRect( 0283 hbCenter.x() * 0.5, 0284 hbCenter.y(), 0285 hbCenter.x() * 1, 0286 1) 0287 helpItems = [splitter] 0288 for name, yFactor in [(i18n('Move Exposed Tiles Here'), 0.5), 0289 (i18n('Move Concealed Tiles Here'), 1.5)]: 0290 helper = QGraphicsSimpleTextItem(name, self) 0291 helper.setScale(3) 0292 nameRect = QRectF() 0293 nameRect.setSize( 0294 helper.mapToParent(helper.boundingRect()).boundingRect().size()) 0295 center = QPointF(hbCenter) 0296 center.setY(center.y() * yFactor) 0297 helper.setPos(center - nameRect.center()) 0298 if sceneRotation(self) == 180: 0299 rotateCenter(helper, 180) 0300 helpItems.append(helper) 0301 self.__moveHelper = self.scene().createItemGroup(helpItems) 0302 self.__moveHelper.setVisible(True) 0303 else: 0304 if self.__moveHelper: 0305 self.__moveHelper.setVisible(False) 0306 0307 def newLowerMelds(self): 0308 """a list of melds for the hand as it should look after sync""" 0309 return list(self.player.concealedMelds) 0310 0311 0312 class ScoringPlayer(VisiblePlayer, Player): 0313 0314 """Player in a scoring game""" 0315 # pylint: disable=too-many-public-methods 0316 0317 def __init__(self, game, name): 0318 self.handBoard = None # because Player.init calls clearHand() 0319 self.manualRuleBoxes = [] 0320 Player.__init__(self, game, name) 0321 VisiblePlayer.__init__(self) 0322 self.handBoard = ScoringHandBoard(self) 0323 0324 def clearHand(self): 0325 """clears attributes related to current hand""" 0326 Player.clearHand(self) 0327 if self.game and self.game.wall: 0328 # is None while __del__ 0329 self.front = self.game.wall[self.idx] 0330 if hasattr(self, 'sideText'): 0331 self.sideText.board = self.front 0332 if isAlive(self.handBoard): 0333 self.handBoard.setEnabled(True) 0334 self.handBoard.showMoveHelper() 0335 self.handBoard.uiMelds = [] 0336 self.manualRuleBoxes = [] 0337 0338 def explainHand(self): 0339 """return the hand to be explained""" 0340 return self.hand 0341 0342 @property 0343 def handTotal(self): 0344 """the hand total of this player""" 0345 if self.hasManualScore(): 0346 spValue = Internal.scene.scoringDialog.spValues[self.idx] 0347 return spValue.value() 0348 return self.hand.total() 0349 0350 def hasManualScore(self): 0351 """True if no tiles are assigned to this player""" 0352 if Internal.scene.scoringDialog: 0353 return Internal.scene.scoringDialog.spValues[self.idx].isEnabled() 0354 return False 0355 0356 def refreshManualRules(self, sender=None): 0357 """update status of manual rules""" 0358 assert Internal.scene 0359 if not self.handBoard: 0360 # might happen at program exit 0361 return 0362 currentScore = self.hand.score 0363 hasManualScore = self.hasManualScore() 0364 for box in self.manualRuleBoxes: 0365 applicable = bool(self.hand.manualRuleMayApply(box.rule)) 0366 if hasManualScore: 0367 # only those rules which do not affect the score can be applied 0368 applicable = applicable and box.rule.hasNonValueAction() 0369 elif box != sender: 0370 applicable = applicable and self.__ruleChangesScore( 0371 box, currentScore) 0372 box.setApplicable(applicable) 0373 0374 def __ruleChangesScore(self, box, currentScore): 0375 """does the rule actually influence the result? 0376 if the action would only influence the score and the rule does not change the score, 0377 ignore the rule. If however the action does other things like penalties leave it applicable""" 0378 if box.rule.hasNonValueAction(): 0379 return True 0380 with BlockSignals(box): 0381 try: 0382 checked = box.isChecked() 0383 box.setChecked(not checked) 0384 newHand = self.computeHand() 0385 finally: 0386 box.setChecked(checked) 0387 return newHand.score > currentScore 0388 0389 def __mjstring(self): 0390 """compile hand info into a string as needed by the scoring engine""" 0391 if self.lastTile and self.lastTile.isConcealed: 0392 lastSource = TileSource.LivingWall.char 0393 else: 0394 lastSource = TileSource.LivingWallDiscard.char 0395 announcements = set() 0396 rules = [x.rule for x in self.manualRuleBoxes if x.isChecked()] 0397 for rule in rules: 0398 options = rule.options 0399 if 'lastsource' in options: 0400 if lastSource != TileSource.East14th.char: 0401 # this defines precedences for source of last tile 0402 lastSource = options['lastsource'] 0403 if 'announcements' in options: 0404 announcements |= set(options['announcements']) 0405 return ''.join(['m', lastSource, ''.join(sorted(announcements))]) 0406 0407 def __lastString(self): 0408 """compile hand info into a string as needed by the scoring engine""" 0409 if not self.lastTile: 0410 return '' 0411 if not self.handBoard.tilesByElement(self.lastTile): 0412 # this happens if we remove the meld with lastTile from the hand 0413 # again 0414 return '' 0415 return 'L%s%s' % (self.lastTile, self.lastMeld) 0416 0417 def computeHand(self): 0418 """return a Hand object, using a cache""" 0419 self.lastTile = Internal.scene.computeLastTile() 0420 self.lastMeld = Internal.scene.computeLastMeld() 0421 string = ' '.join( 0422 [self.scoringString(), 0423 self.__mjstring(), 0424 self.__lastString()]) 0425 return Hand(self, string) 0426 0427 def sortRulesByX(self, rules): 0428 """if this game has a GUI, sort rules by GUI order of the melds they are applied to""" 0429 withMelds = [x for x in rules if x.meld] 0430 withoutMelds = [x for x in rules if x not in withMelds] 0431 tuples = [tuple([x, self.handBoard.findUIMeld(x.meld)]) for x in withMelds] 0432 tuples = sorted(tuples, key=lambda x: x[1][0].sortKey()) 0433 return [x[0] for x in tuples] + withoutMelds 0434 0435 def addMeld(self, meld): 0436 """add meld to this hand in a scoring game""" 0437 if meld.isBonus: 0438 self._bonusTiles.append(meld[0]) 0439 if Debug.scoring: 0440 logDebug('{} gets bonus tile {}'.format(self, meld[0])) 0441 elif meld.isConcealed and not meld.isKong: 0442 self._concealedMelds.append(meld) 0443 if Debug.scoring: 0444 logDebug('{} gets concealed meld {}'.format(self, meld)) 0445 else: 0446 self._exposedMelds.append(meld) 0447 if Debug.scoring: 0448 logDebug('{} gets exposed meld {}'.format(self, meld)) 0449 self._hand = None 0450 0451 def removeMeld(self, uiMeld): 0452 """remove a meld from this hand in a scoring game""" 0453 meld = uiMeld.meld 0454 if meld.isBonus: 0455 self._bonusTiles.remove(meld[0]) 0456 if Debug.scoring: 0457 logDebug('{} loses bonus tile {}'.format(self, meld[0])) 0458 else: 0459 popped = None 0460 for melds in [self._concealedMelds, self._exposedMelds]: 0461 for idx, myMeld in enumerate(melds): 0462 if myMeld == meld: 0463 popped = melds.pop(idx) 0464 break 0465 if not popped: 0466 logDebug( 0467 '%s: %s.removeMeld did not find %s' % 0468 (self.name, self.__class__.__name__, meld), showStack=3) 0469 logDebug(' concealed: %s' % self._concealedMelds) 0470 logDebug(' exposed: %s' % self._exposedMelds) 0471 else: 0472 if Debug.scoring: 0473 logDebug('{} lost meld {}'.format(self, popped)) 0474 self._hand = None 0475 0476 0477 class ScoringGame(Game): 0478 0479 """we play manually on a real table with real tiles and use 0480 Kajongg only for scoring""" 0481 playerClass = ScoringPlayer 0482 wallClass = UIWall 0483 0484 def __init__(self, names, ruleset, 0485 gameid=None, client=None, wantedGame=None): 0486 Game.__init__( 0487 self, 0488 names, 0489 ruleset, 0490 gameid=gameid, 0491 client=client, 0492 wantedGame=wantedGame) 0493 self.shouldSave = True 0494 scene = Internal.scene 0495 scene.selectorBoard.load(self) 0496 self.prepareHand() 0497 self.initHand() 0498 Internal.scene.mainWindow.adjustMainView() 0499 Internal.scene.mainWindow.updateGUI() 0500 self.wall.decorate4() 0501 self.throwDices() 0502 0503 @Game.seed.getter 0504 def seed(self): # looks like a pylint bug pylint: disable=invalid-overridden-method 0505 """a scoring game never has a seed""" 0506 return None 0507 0508 def _setHandSeed(self): 0509 """a scoring game does not need this""" 0510 return None 0511 0512 def prepareHand(self): 0513 """prepare a scoring game hand""" 0514 Game.prepareHand(self) 0515 if not self.finished(): 0516 selector = Internal.scene.selectorBoard 0517 selector.refill() 0518 selector.hasLogicalFocus = True 0519 self.wall.build(shuffleFirst=False) 0520 0521 def nextScoringHand(self): 0522 """save hand to database, update score table and balance in status line, prepare next hand""" 0523 if self.winner: 0524 for player in self.players: 0525 player.usedDangerousFrom = None 0526 for ruleBox in player.manualRuleBoxes: 0527 rule = ruleBox.rule 0528 if rule.name == 'Dangerous Game' and ruleBox.isChecked(): 0529 self.winner.usedDangerousFrom = player 0530 self.saveHand() 0531 self.maybeRotateWinds() 0532 self.prepareHand() 0533 self.initHand() 0534 Internal.scene.scoringDialog.clear() 0535 0536 def close(self): 0537 """log off from the server and return a Deferred""" 0538 scene = Internal.scene 0539 scene.selectorBoard.uiTiles = [] 0540 scene.selectorBoard.allSelectorTiles = [] 0541 if isAlive(scene): 0542 scene.removeTiles() 0543 for player in self.players: 0544 player.hide() 0545 if self.wall: 0546 self.wall.hide() 0547 SideText.removeAll() 0548 return Game.close(self) 0549 0550 @staticmethod 0551 def isScoringGame(): 0552 """are we scoring a manual game?""" 0553 return True 0554 0555 def saveStartTime(self): 0556 """write a new entry in the game table with the selected players""" 0557 Game.saveStartTime(self) 0558 # for PlayingGame, this one is already done in 0559 # Connection.__updateServerInfoInDatabase 0560 known = Query('update server set lastruleset=? where url=?', 0561 (self.ruleset.rulesetId, Query.localServerName)) 0562 if not known: 0563 Query('insert into server(url,lastruleset) values(?,?)', 0564 (self.ruleset.rulesetId, Query.localServerName)) 0565 0566 def _setGameId(self): 0567 """get a new id""" 0568 if not self.gameid: 0569 # a loaded game has gameid already set 0570 self.gameid = self._newGameId() 0571 0572 def _mustExchangeSeats(self, pairs): 0573 """filter: which player pairs should really swap places?""" 0574 # pylint: disable=no-self-use 0575 # I do not understand the logic of the exec return value. The yes button returns 0 0576 # and the no button returns 1. According to the C++ doc, the return value is an 0577 # opaque value that should not be used.""" 0578 return [x for x in pairs if SwapDialog(x).exec_() == 0] 0579 0580 def savePenalty(self, player, offense, amount): 0581 """save computed values to database, update score table and balance in status line""" 0582 scoretime = datetime.datetime.now().replace(microsecond=0).isoformat() 0583 Query("INSERT INTO SCORE " 0584 "(game,penalty,hand,data,manualrules,player,scoretime," 0585 "won,prevailing,wind,points,payments, balance,rotated,notrotated) " 0586 "VALUES(%d,1,%d,?,?,%d,'%s',%d,'%s','%s',%d,%d,%d,%d,%d)" % 0587 (self.gameid, self.handctr, player.nameid, 0588 scoretime, int(player == self.winner), 0589 self.roundWind, player.wind, 0, 0590 amount, player.balance, self.rotated, self.notRotated), 0591 (player.hand.string, offense.name)) 0592 Internal.mainWindow.updateGUI() 0593 0594 def scoreGame(): 0595 """show all games, select an existing game or create a new game""" 0596 Players.load() 0597 if len(Players.humanNames) < 4: 0598 logWarning( 0599 i18n('Please define four players in <interface>Settings|Players</interface>')) 0600 return None 0601 gameSelector = Games(Internal.mainWindow) 0602 selected = None 0603 if not gameSelector.exec_(): 0604 return None 0605 selected = gameSelector.selectedGame 0606 gameSelector.close() 0607 if selected is not None: 0608 return ScoringGame.loadFromDB(selected) 0609 selectDialog = SelectPlayers() 0610 if not selectDialog.exec_(): 0611 return None 0612 return ScoringGame(list(zip(Wind.all4, selectDialog.names)), selectDialog.cbRuleset.current)