File indexing completed on 2024-04-28 07:51:10

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)