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

0001 # -*- coding: utf-8 -*-
0002 
0003 """Copyright (C) 2009-2016 Wolfgang Rohdewald <wolfgang@rohdewald.de>
0004 
0005 SPDX-License-Identifier: GPL-2.0
0006 
0007 Read the user manual for a description of the interface to this scoring engine
0008 """
0009 
0010 import types
0011 from hashlib import md5
0012 
0013 from common import Internal, Debug
0014 from common import StrMixin
0015 from log import logException, logDebug
0016 from mi18n import i18n, i18nc, i18nE, i18ncE, english
0017 from query import Query
0018 
0019 
0020 class Score(StrMixin):
0021 
0022     """holds all parts contributing to a score. It has two use cases:
0023     1. for defining what a rules does: either points or doubles or limits, holding never more than one unit
0024     2. for summing up the scores of all rules: Now more than one of the units can be in use. If a rule
0025     should want to set more than one unit, split it into two rules.
0026     For the first use case only we have the attributes value and unit"""
0027 
0028     __hash__ = None
0029 
0030     def __init__(self, points=0, doubles=0, limits=0, ruleset=None):
0031         self.points = 0  # define the types for those values
0032         self.doubles = 0
0033         self.limits = 0.0
0034         self.ruleset = ruleset
0035         self.points = type(self.points)(points)
0036         self.doubles = type(self.doubles)(doubles)
0037         self.limits = type(self.limits)(limits)
0038 
0039     unitNames = {i18nE(
0040         'points'): 0,
0041                  i18ncE('kajongg', 'doubles'): 50,
0042                  i18ncE('kajongg', 'limits'): 9999}
0043 
0044     def clear(self):
0045         """set all to 0"""
0046         self.points = self.doubles = self.limits = 0
0047 
0048     def change(self, unitName, value):
0049         """set value for unitName. If changed, return True"""
0050         oldValue = self.__getattribute__(unitName)
0051         newValue = type(oldValue)(value)
0052         if newValue == oldValue:
0053             return False, None
0054         if newValue:
0055             if unitName == 'points':
0056                 if self.doubles:
0057                     return False, 'Cannot have points and doubles'
0058             if unitName == 'doubles':
0059                 if self.points:
0060                     return False, 'Cannot have points and doubles'
0061         self.__setattr__(unitName, newValue)
0062         return True, None
0063 
0064     def __str__(self):
0065         """make score printable"""
0066         parts = []
0067         if self.points:
0068             parts.append('points=%d' % self.points)
0069         if self.doubles:
0070             parts.append('doubles=%d' % self.doubles)
0071         if self.limits:
0072             parts.append('limits=%f' % self.limits)
0073         return ' '.join(parts)
0074 
0075     def i18nStr(self):
0076         """make score readable for humans, i18n"""
0077         parts = []
0078         if self.points:
0079             parts.append(i18nc('Kajongg', '%1 points', self.points))
0080         if self.doubles:
0081             parts.append(i18nc('Kajongg', '%1 doubles', self.doubles))
0082         if self.limits:
0083             limits = str(self.limits)
0084             if limits.endswith('.0'):
0085                 limits = limits[-2:]
0086             parts.append(i18nc('Kajongg', '%1 limits', limits))
0087         return ' '.join(parts)
0088 
0089     def __eq__(self, other):
0090         """ == comparison """
0091         assert isinstance(other, Score)
0092         return self.points == other.points and self.doubles == other.doubles and self.limits == other.limits
0093 
0094     def __ne__(self, other):
0095         """ != comparison """
0096         return self.points != other.points or self.doubles != other.doubles or self.limits != other.limits
0097 
0098     def __lt__(self, other):
0099         return self.total() < other.total()
0100 
0101     def __le__(self, other):
0102         return self.total() <= other.total()
0103 
0104     def __gt__(self, other):
0105         return self.total() > other.total()
0106 
0107     def __ge__(self, other):
0108         return self.total() >= other.total()
0109 
0110     def __add__(self, other):
0111         """implement adding Score"""
0112         return Score(self.points + other.points, self.doubles + other.doubles,
0113                      max(self.limits, other.limits), self.ruleset or other.ruleset)
0114 
0115     def total(self):
0116         """the total score"""
0117         score = int(self.points * (2 ** self.doubles))
0118         if self.limits:
0119             if self.limits >= 1:
0120                 self.points = self.doubles = 0
0121             elif self.limits * self.ruleset.limit >= score:
0122                 self.points = self.doubles = 0
0123             else:
0124                 self.limits = 0
0125         if self.limits:
0126             return int(round(self.limits * self.ruleset.limit))
0127         if score and not self.ruleset.roofOff:
0128             score = min(score, self.ruleset.limit)
0129         return score
0130 
0131     def __int__(self):
0132         """the total score"""
0133         return self.total()
0134 
0135     def __nonzero__(self):
0136         """for bool() conversion"""
0137         return self.points != 0 or self.doubles != 0 or self.limits != 0
0138 
0139 
0140 class RuleList(list):
0141 
0142     """a list with a name and a description (to be used as hint).
0143     Rules can be indexed by name or index.
0144     Adding a rule either replaces an existing rule or appends it."""
0145 
0146     def __init__(self, listId, name, description):
0147         list.__init__(self)
0148         self.listId = listId
0149         self.name = name
0150         self.description = description
0151 
0152     def pop(self, key):
0153         """find rule, return it, delete it from this list"""
0154         result = self.__getitem__(key)
0155         self.__delitem__(key)
0156         return result
0157 
0158     def __contains__(self, key):
0159         """do we know this rule?"""
0160         if isinstance(key, RuleBase):
0161             key = key.key()
0162         return any(x.key() == key for x in self)
0163 
0164     def __getitem__(self, key):
0165         """find rule by key"""
0166         if isinstance(key, int):
0167             return list.__getitem__(self, key)
0168         for rule in self:
0169             if rule.key() == key:
0170                 return rule
0171         raise KeyError
0172 
0173     def __setitem__(self, key, rule):
0174         """set rule by key"""
0175         if isinstance(key, int):
0176             list.__setitem__(self, key, rule)
0177             return
0178         for idx, oldRule in enumerate(self):
0179             if oldRule.key() == key:
0180                 list.__setitem__(self, idx, rule)
0181                 return
0182         list.append(self, rule)
0183 
0184     def __delitem__(self, key):
0185         """delete this rule"""
0186         if isinstance(key, int):
0187             list.__delitem__(self, key)
0188             return
0189         for idx, rule in enumerate(self):
0190             if rule.key() == key:
0191                 list.__delitem__(self, idx)
0192                 return
0193         raise KeyError
0194 
0195     def append(self, rule):
0196         """do not append"""
0197         raise Exception('do not append %s' % rule)
0198 
0199     def add(self, rule):
0200         """use add instead of append"""
0201         if rule.key() in self:
0202             logException('%s is already defined as %s, not accepting new rule %s/%s' % (
0203                 rule.key(), self[rule.key()].definition, rule.name, rule.definition))
0204         self[rule.key()] = rule
0205 
0206     def createRule(self, name: str, definition: str = '', **kwargs):
0207         """shortcut for simpler definition of predefined rulesets"""
0208         defParts = definition.split('||')
0209         rule = None
0210         description = kwargs.get('description', '')
0211         for cls in [IntRule, BoolRule, StrRule]:
0212             if defParts[0].startswith(cls.prefix):
0213                 rule = cls(
0214                     name,
0215                     definition,
0216                     description=description,
0217                     parameter=kwargs['parameter'])
0218                 break
0219         if not rule:
0220             if 'parameter' in kwargs:
0221                 del kwargs['parameter']
0222             ruleType = type(ruleKey(name) + 'Rule', (Rule, ), {})
0223             rule = ruleType(name, definition, **kwargs)
0224             if defParts[0] == 'FCallingHand':
0225                 parts1 = defParts[1].split('=')
0226                 assert parts1[0] == 'Ohand', definition
0227                 ruleClassName = parts1[1] + 'Rule'
0228                 if ruleClassName not in RuleBase.ruleClasses:
0229                     logDebug(
0230                         'we want %s, definition:%s' %
0231                         (ruleClassName, definition))
0232                     logDebug('we have %s' % RuleBase.ruleClasses.keys())
0233                 ruleType.limitHand = RuleBase.ruleClasses[ruleClassName]
0234         self.add(rule)
0235 
0236 
0237 class UsedRule(StrMixin):
0238 
0239     """use this in scoring, never change class Rule.
0240     If the rule has been used for a meld, pass it"""
0241 
0242     def __init__(self, rule, meld=None):
0243         self.rule = rule
0244         self.meld = meld
0245 
0246     def __str__(self):
0247         result = self.rule.name
0248         if self.meld:
0249             result += ' ' + str(self.meld)
0250         return result
0251 
0252 
0253 class Ruleset:
0254 
0255     """holds a full set of rules: meldRules,handRules,winnerRules.
0256 
0257         predefined rulesets are preinstalled together with Kajongg. They can be customized by the user:
0258         He can copy them and modify the copies in any way. If a game uses a specific ruleset, it
0259         checks the used rulesets for an identical ruleset and refers to that one, or it generates
0260         a new used ruleset.
0261 
0262         The user can select any predefined or customized ruleset for a new game, but she can
0263         only modify customized rulesets.
0264 
0265         For fast comparison for equality of two rulesets, each ruleset has a hash built from
0266         all of its rules. This excludes the splitting rules, IOW exactly the rules saved in the table
0267         rule will be used for computation.
0268 
0269         Rulesets which are templates for new games have negative ids: The ruleset editor only reads
0270         and writes rulesets with negative id.
0271         Rulesets attached to a game have positive ids.
0272         This can lead to a situation where the same ruleset is twice in the table:
0273         1. clear database
0274         2. play ruleset X which saves it with id=1
0275         3. in the ruleset editor, copy X which saves it with id=-1 and name='Copy of X'
0276 
0277         The name is not unique. Different remote players might save different rulesets
0278         under the same name.
0279     """
0280     # pylint: disable=too-many-instance-attributes
0281 
0282     __hash__ = None
0283 
0284     cache = dict()
0285     hits = 0
0286     misses = 0
0287 
0288     @staticmethod
0289     def cached(name):
0290         """If a Ruleset instance is never changed, we can use a cache"""
0291         if isinstance(name, list):
0292             # we got the rules over the wire
0293             _, wiredHash, _, _ = name[0]
0294         else:
0295             wiredHash = None
0296         for predefined in PredefinedRuleset.rulesets():
0297             if predefined.hash in (name, wiredHash):
0298                 return predefined
0299         cache = Ruleset.cache
0300         if not isinstance(name, list) and name in cache:
0301             return cache[name]
0302         result = Ruleset(name)
0303         cache[result.rulesetId] = result
0304         cache[result.hash] = result
0305         return result
0306 
0307     def __init__(self, name):
0308         """name may be:
0309             - an integer: ruleset.id from the sql table
0310             - a list: the full ruleset specification (probably sent from the server)
0311             - a string: The hash value of a ruleset"""
0312         Rule.importRulecode()
0313         self.name = name
0314         self.rulesetId = 0
0315         self.__hash = None
0316         self.allRules = []
0317         self.__dirty = False  # only the ruleset editor is supposed to make us dirty
0318         self.__loaded = False
0319         self.__filteredLists = {}
0320         self.description = None
0321         self.rawRules = None  # used when we get the rules over the network
0322         self.doublingMeldRules = []
0323         self.doublingHandRules = []
0324         self.standardMJRule = None
0325         self.meldRules = RuleList(1, i18n('Meld Rules'),
0326                                   i18n('Meld rules are applied to single melds independent of the rest of the hand'))
0327         self.handRules = RuleList(2, i18n('Hand Rules'),
0328                                   i18n('Hand rules are applied to the entire hand, for all players'))
0329         self.winnerRules = RuleList(3, i18n('Winner Rules'),
0330                                     i18n('Winner rules are applied to the entire hand but only for the winner'))
0331         self.loserRules = RuleList(33, i18n('Loser Rules'),
0332                                    i18n('Loser rules are applied to the entire hand but only for non-winners'))
0333         self.mjRules = RuleList(4, i18n('Mah Jongg Rules'),
0334                                 i18n('Only hands matching a Mah Jongg rule can win'))
0335         self.parameterRules = RuleList(999, i18nc('kajongg', 'Options'),
0336                                        i18n('Here we have several special game related options'))
0337         self.penaltyRules = RuleList(9999, i18n('Penalties'), i18n(
0338             """Penalties are applied manually by the user. They are only used for scoring games.
0339 When playing against the computer or over the net, Kajongg will never let you get
0340 into a situation where you have to pay a penalty"""))
0341         self.ruleLists = list(
0342             [self.meldRules, self.handRules, self.mjRules, self.winnerRules,
0343              self.loserRules, self.parameterRules, self.penaltyRules])
0344         # the order of ruleLists is the order in which the lists appear in the ruleset editor
0345         # if you ever want to remove an entry from ruleLists: make sure its listId is not reused or you get
0346         # in trouble when updating
0347         self._initRuleset()
0348 
0349     @property
0350     def dirty(self):
0351         """have we been modified since load or last save?"""
0352         return self.__dirty
0353 
0354     @dirty.setter
0355     def dirty(self, dirty):
0356         """have we been modified since load or last save?"""
0357         self.__dirty = dirty
0358         if dirty:
0359             self.__computeHash()
0360 
0361     @property
0362     def hash(self):
0363         """a md5sum computed from the rules but not name and description"""
0364         if not self.__hash:
0365             self.__computeHash()
0366         return self.__hash
0367 
0368     def __eq__(self, other):
0369         """two rulesets are equal if everything except name or description is identical.
0370         The name might be localized."""
0371         return other and self.hash == other.hash
0372 
0373     def __ne__(self, other):
0374         """two rulesets are equal if everything except name or description is identical.
0375         The name might be localized."""
0376         return not other or self.hash != other.hash
0377 
0378     def minMJTotal(self):
0379         """the minimum score for Mah Jongg including all winner points. This is not accurate,
0380         the correct number is bigger in CC: 22 and not 20. But it is enough saveguard against
0381         entering impossible scores for manual games.
0382         We only use this for scoring games."""
0383         return self.minMJPoints + min(x.score.total() for x in self.mjRules)
0384 
0385     @staticmethod
0386     def hashIsKnown(value):
0387         """return False or True"""
0388         result = any(x.hash == value for x in PredefinedRuleset.rulesets())
0389         if not result:
0390             query = Query("select id from ruleset where hash=?", (value,))
0391             result = bool(query.records)
0392         return result
0393 
0394     def _initRuleset(self):
0395         """load ruleset headers but not the rules"""
0396         if isinstance(self.name, int):
0397             query = Query(
0398                 "select id,hash,name,description from ruleset where id=?", (self.name,))
0399         elif isinstance(self.name, list):
0400             # we got the rules over the wire
0401             self.rawRules = self.name[1:]
0402             (self.rulesetId, self.__hash, self.name,
0403              self.description) = self.name[0]
0404             self.load()
0405                       # load raw rules at once, rules from db only when needed
0406             return
0407         else:
0408             query = Query("select id,hash,name,description from ruleset where hash=?", (self.name,))
0409         if query.records:
0410             (self.rulesetId, self.__hash, self.name,
0411              self.description) = query.records[0]
0412         else:
0413             raise Exception('ruleset %s not found' % self.name)
0414 
0415     def __setParametersFrom(self, fromRuleset):
0416         """set attributes for parameters defined in fromRuleset.
0417         Does NOT overwrite already set parameters: Silently ignore them"""
0418         for par in fromRuleset.parameterRules:
0419             if isinstance(par, ParameterRule):
0420                 if par.parName not in self.__dict__:
0421                     self.__dict__[par.parName] = par.parameter
0422 
0423     def load(self):
0424         """load the ruleset from the database and compute the hash. Return self."""
0425         if self.__loaded:
0426             return self
0427         self.__loaded = True
0428         self.loadRules()
0429         self.__setParametersFrom(self)
0430         for ruleList in self.ruleLists:
0431             assert len(ruleList) == len({x.key()
0432                                          for x in ruleList}), '%s has non-unique key' % ruleList.name
0433             for rule in ruleList:
0434                 if hasattr(rule, 'score'):
0435                     rule.score.ruleset = self
0436                 self.allRules.append(rule)
0437         if self.rulesetId:  # a saved ruleset, do not do this for predefined rulesets
0438             # we might have introduced new parameter rules which do not exist in this ruleset saved with the game,
0439             # so add missing parameters from the predefined ruleset most
0440             # similar to this one
0441             self.__setParametersFrom(
0442                 sorted(PredefinedRuleset.rulesets(),
0443                        key=lambda x: len(self.diff(x)))[0])
0444         self.doublingMeldRules = [x for x in self.meldRules if x.score.doubles]
0445         self.doublingHandRules = [x for x in self.handRules if x.score.doubles]
0446         for mjRule in self.mjRules:
0447             if mjRule.__class__.__name__ == 'StandardMahJonggRule':
0448                 self.standardMJRule = mjRule
0449                 break
0450         assert self.standardMJRule
0451         return self
0452 
0453     def __loadQuery(self):
0454         """return a Query object with loaded ruleset"""
0455         return Query(
0456             "select ruleset, list, position, name, definition, points, doubles, limits, parameter from rule "
0457             "where ruleset=? order by list,position", (self.rulesetId,))
0458 
0459     def toList(self):
0460         """return entire ruleset encoded in a string"""
0461         self.load()
0462         result = [[self.rulesetId, self.hash, self.name, self.description]]
0463         result.extend(self.ruleRecord(x) for x in self.allRules)
0464         return result
0465 
0466     def loadRules(self):
0467         """load rules from database or from self.rawRules (got over the net)"""
0468         for record in self.rawRules or self.__loadQuery().records:
0469             self.__loadRule(record)
0470 
0471     def __loadRule(self, record):
0472         """loads a rule into the correct ruleList"""
0473         _, listNr, _, name, definition, points, doubles, limits, parameter = record
0474         try:
0475             points = int(points)
0476         except ValueError:
0477             # this happens if the unit changed from limits to points but the value
0478             # is not converted at the same time
0479             points = int(float(points))
0480         for ruleList in self.ruleLists:
0481             if ruleList.listId == listNr:
0482                 ruleList.createRule(
0483                     name, definition, points=points, doubles=int(doubles), limits=float(limits),
0484                     parameter=parameter)
0485                 break
0486 
0487     def findUniqueOption(self, action):
0488         """return first rule with option"""
0489         rulesWithAction = [x for x in self.allRules if action in x.options]
0490         assert len(rulesWithAction) < 2, '%s has too many matching rules for %s' % (
0491             str(self), action)
0492         if rulesWithAction:
0493             return rulesWithAction[0]
0494         return None
0495 
0496     def filterRules(self, attrName):
0497         """return all my Rule classes having attribute attrName"""
0498         if attrName not in self.__filteredLists:
0499             self.__filteredLists[attrName] = [x for x in self.allRules if hasattr(x, attrName)]
0500         return self.__filteredLists[attrName]
0501 
0502     @staticmethod
0503     def newId(minus=False):
0504         """return an unused ruleset id. This is not multi user safe."""
0505         func = 'min(id)-1' if minus else 'max(id)+1'
0506         result = -1 if minus else 1
0507         records = Query("select %s from ruleset" % func).records
0508         if records and records[0] and records[0][0]:
0509             try:
0510                 result = int(records[0][0])
0511             except ValueError:
0512                 pass
0513         return result
0514 
0515     @staticmethod
0516     def nameExists(name):
0517         """return True if ruleset name is already in use"""
0518         result = any(x.name == name for x in PredefinedRuleset.rulesets())
0519         if not result:
0520             result = bool(
0521                 Query('select id from ruleset where id<0 and name=?', (name,)).records)
0522         return result
0523 
0524     def _newKey(self, minus=False):
0525         """generate a new id and a new name if the name already exists"""
0526         newId = self.newId(minus=minus)
0527         newName = self.name
0528         if minus:
0529             copyNr = 1
0530             while self.nameExists(newName):
0531                 copyStr = ' ' + str(copyNr) if copyNr > 1 else ''
0532                 newName = i18nc(
0533                     'Ruleset._newKey:%1 is empty or space plus number',
0534                     'Copy%1 of %2', copyStr, i18n(self.name))
0535                 copyNr += 1
0536         return newId, newName
0537 
0538     def clone(self):
0539         """return a clone of self, unloaded"""
0540         return Ruleset(self.rulesetId)
0541 
0542     def __str__(self):
0543         return 'type=%s, id=%d,rulesetId=%d,name=%s' % (
0544             type(self), id(self), self.rulesetId, self.name)
0545 
0546     def copyTemplate(self):
0547         """make a copy of self and return the new ruleset id. Returns the new ruleset.
0548         To be used only for ruleset templates"""
0549         newRuleset = self.clone().load()
0550         newRuleset.save(minus=True, forced=True)
0551         if isinstance(newRuleset, PredefinedRuleset):
0552             newRuleset = Ruleset(newRuleset.rulesetId)
0553         return newRuleset
0554 
0555     def __ruleList(self, rule):
0556         """return the list containing rule. We could make the list
0557         an attribute of the rule but then we rarely
0558         need this, and it is not time critical"""
0559         for ruleList in self.ruleLists:
0560             if rule in ruleList:
0561                 return ruleList
0562         assert False
0563         return None
0564 
0565     def rename(self, newName):
0566         """renames the ruleset. returns True if done, False if not"""
0567         with Internal.db:
0568             if self.nameExists(newName):
0569                 return False
0570             query = Query(
0571                 "update ruleset set name=? where id<0 and name=?", (newName, self.name))
0572             if not query.failure:
0573                 self.name = newName
0574             return not query.failure
0575 
0576     def remove(self):
0577         """remove this ruleset from the database."""
0578         with Internal.db:
0579             Query("DELETE FROM rule WHERE ruleset=?", (self.rulesetId,))
0580             Query("DELETE FROM ruleset WHERE id=?", (self.rulesetId,))
0581 
0582     def __computeHash(self):
0583         """compute the hash for this ruleset using all rules but not name and
0584         description of the ruleset"""
0585         self.load()
0586         result = md5()
0587         for rule in sorted(self.allRules, key=Rule.__str__):
0588             result.update(rule.hashStr().encode('utf-8'))
0589         self.__hash = result.hexdigest()
0590 
0591     def ruleRecord(self, rule):
0592         """return the rule as tuple, prepared for use by sql. The first three
0593         fields are the primary key."""
0594         score = rule.score
0595         ruleList = None
0596         for ruleList in self.ruleLists:
0597             if rule in ruleList:
0598                 ruleIdx = ruleList.index(rule)
0599                 break
0600         assert rule in ruleList, '%s: %s not in list %s' % (
0601             type(rule), rule, ruleList.name)
0602         return (self.rulesetId, ruleList.listId, ruleIdx, rule.name,
0603                 rule.definition, score.points, score.doubles, score.limits, rule.parameter)
0604 
0605     def updateRule(self, rule):
0606         """update rule in database"""
0607         self.__hash = None  # invalidate, will be recomputed when needed
0608         with Internal.db:
0609             record = self.ruleRecord(rule)
0610             Query("UPDATE rule SET name=?, definition=?, points=?, doubles=?, limits=?, parameter=? "
0611                   "WHERE ruleset=? AND list=? AND position=?",
0612                   tuple(record[3:] + record[:3]))
0613             Query(
0614                 "UPDATE ruleset SET hash=? WHERE id=?",
0615                 (self.hash,
0616                  self.rulesetId))
0617 
0618     def save(self, minus=False, forced=False):
0619         """save the ruleset to the database.
0620         If it does not yet exist in database, give it a new id
0621         If the name already exists in the database, also give it a new name
0622         If the hash already exists in the database, only save if forced=True"""
0623         if not forced:
0624             if minus:
0625                 # if we save a template, only check for existing templates. Otherwise this could happen:
0626                 # clear kajongg.db, play game with DMJL, start ruleset editor, copy DMJL.
0627                 # since play Game saved the used ruleset with id 1, id 1 is found here and no new
0628                 # template is generated. Next the ruleset editor shows the original ruleset in italics
0629                 # and the copy with normal font but identical name, and the
0630                 # copy is never saved.
0631                 qData = Query(
0632                     "select id from ruleset where hash=? and id<0", (self.hash,)).records
0633             else:
0634                 qData = Query(
0635                     "select id from ruleset where hash=?", (self.hash,)).records
0636             if qData:
0637                 # is already in database
0638                 self.rulesetId = qData[0][0]
0639                 return
0640         with Internal.db:
0641             self.rulesetId, self.name = self._newKey(minus)
0642             Query(
0643                 'INSERT INTO ruleset(id,name,hash,description) VALUES(?,?,?,?)',
0644                 (self.rulesetId, english(self.name),
0645                  self.hash, self.description),
0646                 failSilent=True)
0647             cmd = 'INSERT INTO rule(ruleset, list, position, name, definition, ' \
0648                 'points, doubles, limits, parameter) VALUES(?,?,?,?,?,?,?,?,?)'
0649             args = [self.ruleRecord(x) for x in self.allRules]
0650             Query(cmd, args)
0651 
0652     @staticmethod
0653     def availableRulesets():
0654         """return all rulesets defined in the database plus all predefined rulesets"""
0655         templateIds = (x[0]
0656                        for x in Query("SELECT id FROM ruleset WHERE id<0").records)
0657         result = [Ruleset(x) for x in templateIds]
0658         for predefined in PredefinedRuleset.rulesets():
0659             if predefined not in result or predefined.name not in [x.name for x in result]:
0660                 result.append(predefined)
0661         return result
0662 
0663     @staticmethod
0664     def selectableRulesets(server=None):
0665         """return all selectable rulesets for a new game.
0666         server is used to find the last ruleset used by us on that server, this
0667         ruleset will returned first in the list."""
0668         result = Ruleset.availableRulesets()
0669         # if we have a selectable ruleset with the same name as the last used ruleset
0670         # put that ruleset in front of the list. We do not want to use the exact same last used
0671         # ruleset because we might have made some fixes to the ruleset
0672         # meanwhile
0673         if server is None:  # scoring game
0674             # the exists clause is only needed for inconsistent data bases
0675             qData = Query("select ruleset from game where seed is null "
0676                           " and exists(select id from ruleset where game.ruleset=ruleset.id)"
0677                           "order by starttime desc limit 1").records
0678         else:
0679             qData = Query(
0680                 'select lastruleset from server where lastruleset is not null and url=?',
0681                 (server,)).records
0682             if not qData:
0683                 # we never played on that server
0684                 qData = Query('select lastruleset from server where lastruleset is not null '
0685                               'order by lasttime desc limit 1').records
0686         if qData:
0687             lastUsedId = qData[0][0]
0688             qData = Query(
0689                 "select name from ruleset where id=?",
0690                 (lastUsedId,
0691                 )).records
0692             if qData:
0693                 lastUsed = qData[0][0]
0694                 for idx, ruleset in enumerate(result):
0695                     if ruleset.name == lastUsed:
0696                         del result[idx]
0697                         return [ruleset] + result
0698         return result
0699 
0700     def diff(self, other):
0701         """return a list of tuples. Every tuple holds one or two rules: tuple[0] is from self, tuple[1] is from other"""
0702         result = []
0703         leftDict = {x.name: x for x in self.allRules}
0704         rightDict = {x.name: x for x in other.allRules}
0705         left = set(leftDict.keys())
0706         right = set(rightDict.keys())
0707         for rule in left & right:
0708             leftRule, rightRule = leftDict[rule], rightDict[rule]
0709             if str(leftRule) != str(rightRule):
0710                 result.append((leftRule, rightRule))
0711         for rule in left - right:
0712             result.append((leftDict[rule], None))
0713         for rule in right - left:
0714             result.append((None, rightDict[rule]))
0715         return result
0716 
0717 
0718 class RuleBase(StrMixin):
0719 
0720     """a base for standard Rule and parameter rules IntRule, StrRule, BoolRule"""
0721 
0722     options = {}
0723     ruleClasses = {}
0724 
0725     def __init__(self, name: str, definition: str, description: str):
0726         self.hasSelectable = False
0727         self.ruleClasses[self.__class__.__name__] = self.__class__
0728         self.__name = name
0729         self.definition = definition
0730         self.description = description
0731 
0732     @property
0733     def name(self):
0734         """name is readonly"""
0735         return self.__name
0736 
0737     def validate(self):  # pylint: disable=no-self-use
0738         """is the rule valid?"""
0739         return True
0740 
0741     def hashStr(self):  # pylint: disable=no-self-use
0742         """
0743         all that is needed to hash this rule
0744 
0745         @return: The unique hash string
0746         @rtype: str
0747         """
0748         return ''
0749 
0750     def __str__(self):
0751         return self.hashStr()
0752 
0753 
0754 def ruleKey(name):
0755     """the key is used for finding a rule in a RuleList"""
0756     return english(name).replace(' ', '').replace('.', '')
0757 
0758 
0759 class Rule(RuleBase):
0760 
0761     """a mahjongg rule with a name, matching variants, and resulting score.
0762     The rule applies if at least one of the variants matches the hand.
0763     For parameter rules, only use name, definition,parameter. definition must start with int or str
0764     which is there for loading&saving, but internally is stripped off."""
0765     # pylint: disable=too-many-arguments,too-many-instance-attributes
0766 
0767     ruleCode = {}
0768     limitHand = None
0769 
0770     @classmethod
0771     def memoize(cls, func, srcClass):
0772         """cache results for func"""
0773         code = func.__code__
0774         clsMethod = code.co_varnames[0] == 'cls'
0775 
0776         def wrapper(*args):
0777             """closure"""
0778             hand = args[1] if clsMethod else args[0]
0779             cacheKey = (cls, func.__name__)
0780             if cacheKey not in hand.ruleCache:
0781                 result = func(*args)
0782                 hand.ruleCache[cacheKey] = result
0783                 if Debug.ruleCache:
0784                     hand.debug(
0785                         'new ruleCache entry for hand %s: %s=%s' %
0786                         (id(hand) %
0787                          10000, cacheKey, result))
0788                 return result
0789             if Debug.ruleCache:
0790                 if hand.ruleCache[cacheKey] != func(*args):
0791                     hand.player.game.debug(
0792                         'cacheKey=%s rule=%s func:%s args:%s' %
0793                         (cacheKey, srcClass, func, args))
0794                     hand.player.game.debug(
0795                         '  hand:%s/%s' %
0796                         (id(hand), hand))
0797                     hand.player.game.debug(
0798                         '  cached:%s ' %
0799                         str(hand.ruleCache[cacheKey]))
0800                     hand.player.game.debug(
0801                         '    real:%s ' %
0802                         str(func(*args)))
0803             return hand.ruleCache[cacheKey]
0804         return classmethod(wrapper) if clsMethod else staticmethod(wrapper)
0805 
0806     def __init__(self, name, definition='', points=0, doubles=0, limits=0,
0807                  description=None, explainTemplate=None, debug=False):
0808         RuleBase.__init__(self, name, definition, description)
0809         self.hasSelectable = False
0810         self.explainTemplate = explainTemplate
0811         self.score = Score(points, doubles, limits)
0812         self.parameter = 0
0813         self.debug = debug
0814         self.__parseDefinition()
0815 
0816     @staticmethod
0817     def redirectTo(srcClass, destClass, memoize=False):
0818         """inject my static and class methods into destClass,
0819         converting methods to staticmethod/classmethod as needed"""
0820         # also for inherited methods
0821         classes = list(reversed(srcClass.__mro__[:-2]))
0822         combinedDict = dict(classes[0].__dict__)
0823         for ancestor in classes[1:]:
0824             combinedDict.update(ancestor.__dict__)
0825         for funcName, method in combinedDict.items():
0826             if isinstance(method, (types.FunctionType, classmethod, staticmethod)):
0827                 if hasattr(method, 'im_func'):
0828                     method = method.__func__
0829                 elif hasattr(method, '__func__'):
0830                     method = method.__func__
0831                 if memoize and method.__name__ in srcClass.cache:
0832                     method = destClass.memoize(method, srcClass)
0833                 else:
0834                     if method.__code__.co_varnames[0] == 'cls':
0835                         methodType = classmethod
0836                     else:
0837                         methodType = staticmethod
0838                     method = methodType(method)
0839                 setattr(destClass, funcName, method)
0840 
0841     @classmethod
0842     def importRulecode(cls):
0843         """for every RuleCode class defined in this module,
0844         generate an instance and add it to dict Rule.ruleImpl.
0845         Also convert all RuleCode methods into classmethod or staticmethod"""
0846         if not cls.ruleCode:
0847             import rulecode
0848             for ruleClass in rulecode.__dict__.values():
0849                 if hasattr(ruleClass, "__mro__"):
0850                     if ruleClass.__mro__[-2].__name__ == 'RuleCode' and len(ruleClass.__mro__) > 2:
0851                         cls.ruleCode[ruleClass.__name__] = ruleClass
0852                         # this changes all methods to classmethod or
0853                         # staticmethod
0854                         cls.redirectTo(ruleClass, ruleClass)
0855 
0856     def key(self):
0857         """the key is used for finding a rule in a RuleList"""
0858         return ruleKey(self.name)
0859 
0860     def __parseDefinition(self):
0861         """private setter for definition"""
0862         # pylint: disable=too-many-branches
0863         if not self.definition:
0864             return  # may happen with special programmed rules
0865         variants = self.definition.split('||')
0866         self.__class__.options = {}
0867         self.hasSelectable = False
0868         for idx, variant in enumerate(variants):
0869             variant = str(variant)
0870             if variant[0] == 'F':
0871                 assert idx == 0
0872                 code = self.ruleCode[variant[1:]]
0873                 # when executing code for this rule, we do not want
0874                 # to call those things indirectly
0875                 # pylint: disable=attribute-defined-outside-init
0876                 self.redirectTo(code, self.__class__, memoize=True)
0877                 if hasattr(code, 'selectable'):
0878                     self.hasSelectable = True
0879             elif variant[0] == 'O':
0880                 for action in variant[1:].split():
0881                     aParts = action.split('=')
0882                     if len(aParts) == 1:
0883                         aParts.append('None')
0884                     self.options[aParts[0]] = aParts[1]
0885             else:
0886                 pass
0887         self.validate()
0888 
0889     def validate(self):
0890         """check for validity"""
0891         payers = int(self.options.get('payers', 1))
0892         payees = int(self.options.get('payees', 1))
0893         if not 2 <= payers + payees <= 4:
0894             logException(
0895                 i18nc(
0896                     '%1 can be a sentence', '%4 have impossible values %2/%3 in rule "%1"',
0897                     self.name, payers, payees, 'payers/payees'))
0898 
0899     def explain(self, meld):
0900         """use this rule for scoring"""
0901         return '%s: %s' % (i18n(
0902             self.explainTemplate if self.explainTemplate else self.name).format(
0903                 group=meld[0].groupName() if meld else '',
0904                 value=meld[0].valueName() if meld else '',
0905                 meldType=meld.typeName() if meld else '',
0906                 meldName=meld.name() if meld else '',
0907                 tileName=meld[0].name() if meld else '').replace(
0908                     '&', '').replace('  ', ' ').strip(), self.score.i18nStr())
0909 
0910     def hashStr(self):
0911         """
0912         all that is needed to hash this rule. Try not to change this to keep
0913         database congestion low.
0914 
0915         @return: The unique hash string
0916         @rtype: str
0917         """
0918         return '%s: %s %s' % (self.name, self.definition, self.score)
0919 
0920     def i18nStr(self):
0921         """return a human readable string with the content"""
0922         return self.score.i18nStr()
0923 
0924     @staticmethod
0925     def exclusive():
0926         """True if this rule can only apply to one player"""
0927         return False
0928 
0929     def hasNonValueAction(self):
0930         """Rule has a special action not changing the score directly"""
0931         return bool(any(x not in ['lastsource', 'announcements'] for x in self.options))
0932 
0933 
0934 class ParameterRule(RuleBase):
0935 
0936     """for parameters"""
0937     prefix = 0
0938 
0939     def __init__(self, name, definition, description, parameter):
0940         RuleBase.__init__(self, name, definition, description)
0941         defParts = definition.split('||')
0942         self.parName = defParts[0][len(self.prefix):]
0943         self.score = Score()
0944         self.parameter = self.convertParameter(parameter)
0945 
0946     def key(self):
0947         """the key is used for finding a rule in a RuleList"""
0948         return self.parName
0949 
0950     @staticmethod
0951     def convertParameter(parameter):
0952         """convert string to wanted type"""
0953         return parameter
0954 
0955     def hashStr(self):
0956         """
0957         all that is needed to hash this rule. Try not to change this to keep
0958         database congestion low.
0959 
0960         @return: The unique hash string
0961         @rtype: str
0962         """
0963         result = '%s: %s %s' % (self.name, self.definition, self.parameter)
0964         return result
0965 
0966     def i18nStr(self):
0967         """return a human readable string with the content"""
0968         return str(self.parameter)
0969 
0970 
0971 class IntRule(ParameterRule):
0972 
0973     """for int parameters. Duck typing with Rule"""
0974     prefix = 'int'
0975 
0976     def __init__(self, name, definition, description, parameter):
0977         ParameterRule.__init__(self, name, definition, description, parameter)
0978         self.minimum = 0
0979         for defPart in definition.split('||'):
0980             if defPart.startswith('Omin='):
0981                 self.minimum = int(defPart[5:])
0982 
0983     @staticmethod
0984     def convertParameter(parameter):
0985         """convert string to wanted type"""
0986         return int(parameter)
0987 
0988     def validate(self):
0989         """is the rule valid?"""
0990         if self.parameter < self.minimum:
0991             return i18nc(
0992                 'wrong value for rule', '%1: %2 is too small, minimal value is %3',
0993                 i18n(self.name), self.parName, self.minimum)
0994         return None
0995 
0996 
0997 class BoolRule(ParameterRule):
0998 
0999     """for bool parameters. Duck typing with Rule"""
1000     prefix = 'bool'
1001 
1002     def __init__(self, name, definition, description, parameter):
1003         ParameterRule.__init__(self, name, definition, description, parameter)
1004 
1005     @staticmethod
1006     def convertParameter(parameter):
1007         """convert string to wanted type"""
1008         return parameter not in ('false', 'False', False, 0, '0', None, '')
1009 
1010 
1011 class StrRule(ParameterRule):
1012 
1013     """for str parameters. Duck typing with Rule. Currently not used."""
1014     prefix = 'str'
1015 
1016     def __init__(self, name, definition, description, parameter):
1017         ParameterRule.__init__(self, name, definition, description, parameter)
1018 
1019 
1020 class PredefinedRuleset(Ruleset):
1021 
1022     """special code for loading rules from program code instead of from the database"""
1023 
1024     classes = set()  # only those will be playable
1025     preRulesets = []
1026 
1027     def __init__(self, name=None):
1028         Ruleset.__init__(self, name or 'general predefined ruleset')
1029 
1030     @staticmethod
1031     def rulesets():
1032         """a list of instances for all predefined rulesets"""
1033         if not PredefinedRuleset.preRulesets:
1034             PredefinedRuleset.preRulesets = [
1035                 x() for x in sorted(PredefinedRuleset.classes, key=lambda x: x.__name__)]
1036         return PredefinedRuleset.preRulesets
1037 
1038     def rules(self):
1039         """here the predefined rulesets can define their rules"""
1040 
1041     def clone(self):
1042         """return a clone, unloaded"""
1043         return self.__class__()