Code source de jouets.mpa.graphe

# Copyright 2024 Louis Paternault
#
# This file is part of Jouets.
#
# Jouets is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Jouets is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Jouets.  If not, see <http://www.gnu.org/licenses/>.

"""Outils permettant de définir le graphe correspondant à toutes les histoires possibles."""

from __future__ import annotations

import dataclasses
import operator
import statistics
import types
import typing
from collections.abc import Collection
from numbers import Number

################################################################################


class _Effet:
    def __call__(self, histoire: Histoire):
        raise NotImplementedError

    @property
    def txt(self):
        """Renvoit une version « pour humain » de l'effet."""
        raise NotImplementedError


[docs] @dataclasses.dataclass(init=False) class EffetAffecte(_Effet): """Affecte des choses aux roues. Par exemple, `EffetAffecte(bleu="marteau")` ajoute un marteau à la roue bleue. """ roues: dict def __init__(self, **kwargs): self.roues = kwargs def __call__(self, histoire: Histoire): histoire.roues.update(self.roues) return histoire @property def txt(self): return "\n".join( f"{couleur}={valeur}" for couleur, valeur in self.roues.items() )
[docs] @dataclasses.dataclass(init=False) class EffetAffecteSiVide(_Effet): """Affecte des choses aux roues, si elles sont vides. Par exemple, `EffetAffecteSiVide(bleu="marteau")` ajoute un marteau à la roue bleue, si la roue ne contenait rien (clef absente du dictionnaire, ou valeur égale à None). """ roues: dict def __init__(self, **kwargs): self.roues = kwargs def __call__(self, histoire: Histoire): for clef, valeur in self.roues.items(): if histoire.roues.get(clef, None) is None: histoire.roues[clef] = valeur return histoire @property def txt(self): return "\n".join( f"{couleur}={valeur} (si vide)" for couleur, valeur in self.roues.items() )
[docs] class EffetRien(_Effet): """Ne fait rien.""" def __call__(self, histoire: Histoire): return histoire @property def txt(self): return ""
[docs] @dataclasses.dataclass(init=False) class EffetAjoute(_Effet): """Ajoute une valeur à une des roues.""" ajouts: dict def __init__(self, **kwargs): self.ajouts = kwargs def __call__(self, histoire: Histoire): for couleur, valeur in self.ajouts.items(): histoire.roues[couleur] += valeur return histoire @property def txt(self): return "\n".join( f"{couleur} + {valeur}" if valeur > 0 else f"{couleur} - {abs(valeur)}" for couleur, valeur in self.ajouts.items() )
[docs] @dataclasses.dataclass(init=False) class EffetEt(_Effet): """Applique plusieurs effets.""" effets: Collection[_Effet] def __init__(self, *args): self.effets = args def __call__(self, histoire: Histoire): for effet in self.effets: histoire = effet(histoire) return histoire @property def txt(self): return "\n".join(effet.txt for effet in self.effets)
#: Effets pouvant être appliqués. #: #: .. autoclass:: EffetAffecte #: .. autoclass:: EffetAffecteSiVide #: .. autoclass:: EffetAjoute #: .. autoclass:: EffetRien #: .. autoclass:: EffetEt Effet = types.SimpleNamespace( rien=EffetRien, ajoute=EffetAjoute, affecte=EffetAffecte, affecteSiVide=EffetAffecteSiVide, et=EffetEt, ) ################################################################################ class _Condition: def __call__(self, histoire): raise NotImplementedError @property def txt(self): """Renvoit une version « pour humain » de la condition.""" raise NotImplementedError
[docs] class ConditionVrai(_Condition): """Cette condition est toujours vérifiée.""" def __call__(self, histoire): return True @property def txt(self): return "Vrai"
[docs] @dataclasses.dataclass class ConditionCompte(_Condition): """Le nombre de roues ayant une certaine valeur est comprise dans l'intervalle. Par exemple, ``Condition.compte("bobo", inf=2)`` signifie qu'au moins deux roues ont la valeur ``"bobo"``. """ valeur: str inf: Number = float("-inf") sup: Number = float("inf") def __call__(self, histoire): return ( self.inf <= operator.countOf(histoire.roues.values(), self.valeur) <= self.sup ) @property def txt(self): if self.inf == float("-inf") and self.sup == float("inf"): return "Vrai" if self.inf == float("-inf"): return f"{self.valeur} \u2264 {self.sup}" if self.sup == float("inf"): return f"{self.inf} \u2264 {self.valeur}" return f"{self.inf} \u2264 {self.valeur} \u2264 {self.sup}"
[docs] @dataclasses.dataclass(init=False) class ConditionOu(_Condition): """L'une des conditions données en argument est vérifiée.""" conditions: Collection[_Condition] def __init__(self, *args): self.conditions = args def __call__(self, histoire): return any(condition(histoire) for condition in self.conditions) @property def txt(self): return "\nou\n".join(f"({condition.txt})" for condition in self.conditions)
[docs] @dataclasses.dataclass(init=False) class ConditionEt(_Condition): """Toutes les conditions données en argument sont vérifiées.""" conditions: Collection[_Condition] def __init__(self, *args): self.conditions = args def __call__(self, histoire): return all(condition(histoire) for condition in self.conditions) @property def txt(self): return "\net\n".join(f"({condition.txt})" for condition in self.conditions)
[docs] @dataclasses.dataclass(init=False) class ConditionRoue(_Condition): """Les roues contiennent tous les objets donnés en argument. Par exemple, ``_Condition.roue(jaune="chaussons", rouge="Lina")`` vérifie que - les chaussons sont sur la roue jaune, et - le personnage de la roue rouge est Lina. """ roues: dict def __init__(self, **kwargs): self.roues = kwargs def __call__(self, histoire): for key, value in self.roues.items(): try: if histoire.roues[key] != value: return False except KeyError: return False return True @property def txt(self): return " et ".join( f"{couleur}={valeur}" for couleur, valeur in self.roues.items() )
[docs] @dataclasses.dataclass class ConditionNon(_Condition): """Vérifie que la condition n'est pas satisfaite.""" condition: _Condition def __call__(self, histoire): return not self.condition(histoire) @property def txt(self): return "NON(\n" + self.condition.txt + "\n)"
[docs] @dataclasses.dataclass class ConditionIntervalle(_Condition): """Vérifie que la valeur d'une roue est comprise dans l'intervalle donné""" roue: str inf: float = -float("inf") sup: float = float("inf") def __call__(self, histoire): return self.inf <= histoire.roues[self.roue] <= self.sup @property def txt(self): if self.inf == float("-inf") and self.sup == float("inf"): return "Vrai" if self.inf == float("-inf"): return f"{self.roue} \u2264 {self.sup}" if self.sup == float("inf"): return f"{self.inf} \u2264 {self.roue}" return f"{self.inf} \u2264 {self.roue} \u2264 {self.sup}"
#: Conditions à vérifier pour être autorisé·e à faire ce choix #: #: .. autoclass:: ConditionCompte #: .. autoclass:: ConditionNon #: .. autoclass:: ConditionOu #: .. autoclass:: ConditionEt #: .. autoclass:: ConditionRoue #: .. autoclass:: ConditionVrai #: .. autoclass:: ConditionIntervalle Condition = types.SimpleNamespace( compte=ConditionCompte, non=ConditionNon, ou=ConditionOu, et=ConditionEt, roue=ConditionRoue, vrai=ConditionVrai, intervalle=ConditionIntervalle, ) ################################################################################ ROUES = { "rouge": None, "vert": None, "bleu": None, "jaune": None, }
[docs] @dataclasses.dataclass class Page: """Une page, qui contient plusieurs choix.""" #: Liste des choix possibles choix: Collection[Choix] = dataclasses.field(default_factory=list) #: Si ``None``, le livre n'est pas terminé. #: Sinon, indique la fin (victoire, défaite, ni l'une ni l'autre). fin: typing.Optionnal[str] = None #: Éventuelles valeurs de départ pour les roues. #: Cela n'a de sens que pour le début du livre. roues: typing.Optionnal[dict] = dataclasses.field(default_factory=ROUES.copy) #: Éventuelles descriptions pour les codes descriptions: dict = dataclasses.field(default_factory=dict)
[docs] def iter_pages(self, *, fait: set = None): """Itère sur l'ensemble des pages de l'histoire. :param set fait: Ensemble des pages déjà renvoyées (qui ne le seront pas à nouveau) """ if fait is None: fait = set() yield self fait.add(id(self)) for choix in self.choix: if choix.cible and id(choix.cible) not in fait: yield from choix.cible.iter_pages(fait=fait)
[docs] @dataclasses.dataclass class Choix: """Une alternative possible lors d'un choix""" #: Code qui sera utilisé pour ce choix lorsque les histoires seront affichées. code: str #: Page à laquelle on va si on fait ce choix. cible: Page #: Condition pour pouvoir faire ce choix. Par défaut, aucune condition n'est requise. condition: typing.Callable = Condition.vrai() #: Effet si ce choix est effectué. C'est un des attributs de :data:`Effet`. #: Par exemple, ``effet = Effet.affecte(vert="bouclier")`` signifie : #: « Mettre le bouclier sur la roue verte ». effet: typing.Callable = Effet.rien() def __repr__(self): # pylint: disable=line-too-long return f"{ self.__class__.__name__}(code={ self.code }, cible=< {self.cible.__class__.__name__} at { hex(id(self.cible)) }>, condition={ self.condition }, effet={ self.effet })" def est_direct(self): """Renvoit True si la condition n'a ni condition, ni effet.""" return isinstance(self.effet, EffetRien) and isinstance( self.condition, ConditionVrai )
[docs] class Histoire: """Un récit d'une histoire""" def __init__(self, début, *, roues=None, codes=None): if isinstance(début, list): self.passé = début else: self.passé = [début] if roues is None: self.roues = début.roues else: self.roues = roues if codes is None: self.codes = [] else: self.codes = codes @property def page(self): """Renvoit la page actuelle, c'est-à-dire la dernière page visitée.""" return self.passé[-1]
[docs] def applique(self, choix): """Applique le choix, et renvoit le nouvel objet :class:`Histoire`.""" return choix.effet( self.__class__( self.passé + [choix.cible], roues=self.roues.copy(), codes=self.codes + [choix.code], ) )
[docs] def suivantes(self, *, préfixe=None, condition=False): """Itère l'étape suivantes des histoires - N'effectue que les choix possibles - Applique les effets - Ne fait qu'un seul choix (des appels récursifs à cette fonction sont nécessaires pour continuer à avancer). :param bool condition: Si vrai, itère des tuples `(Histoire, Condition)` où `Histoire` est l'histoire suivante, et `Condition` est la condition qui a été vérifiée pour mener à cette histoire. Sinon, n'itère que les histoires. :param typing.Conditionnal[list[str]] préfixe: Éventuelle liste des choix déjà faits (les autres choix sont ignorés). """ if préfixe is None: préfixe = [] for choix in self.page.choix: if préfixe and choix.code != préfixe[0]: continue if choix.cible in self.passé: continue if choix.condition(self): suivant = self.applique(choix) if condition: yield (suivant, choix.condition) else: yield suivant
[docs] def fins(self): """Renvoie sur toutes les fins possibles""" if self.page.fin is not None: return {self.page.fin} # Itère sur les fins des (potentielles) histoires suivantes, en ignorant les conditions return set.union( *( self.applique(choix).fins() for choix in self.page.choix if choix.cible not in self.passé ) )
[docs] def histoires(self): """Itère sur toutes les histoires possibles.""" if self.page.choix: for histoire in self.suivantes(): yield from histoire.histoires() else: yield self
[docs] def proba(self, fin: str, préfixe: typing.Optionnal(list[str]) = None): """Calcule la probabilité d'obtenir la fin donnée en argument.""" if préfixe is None: préfixe = [] if self.page.fin is not None: # C'est une page finale if self.page.fin == fin: return 1 return 0 # Ce n'est pas une page finale : # Calcule la moyenne des probabilités de de victoire pour chacun des choix. try: return statistics.mean( histoire.proba(fin, préfixe=préfixe[1:] if préfixe else None) for histoire in self.suivantes(préfixe=préfixe) ) except statistics.StatisticsError: # Aucune histoire avec ce préfixe return 0