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 Iterable
from numbers import Number

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


def _effet_affecte(**effet):
    """Affecte des choses aux roues.

    Par exemple, `_effet_affecte(bleu="marteau")` ajoute un marteau à la roue bleue.
    """

    def emballé(histoire: Histoire):
        histoire.roues.update(effet)
        return histoire

    return emballé


def _effet_affecte_si_vide(**effet):
    """Affecte des choses aux roues, si elles sont vides.

    Par exemple, `_effet_affecte(bleu="marteau")` ajoute un marteau à la roue bleue,
    si la roue ne contenait rien (clef absente du dictionnaire, ou valeur égale à None).
    """

    def emballé(histoire: Histoire):
        for clef, valeur in effet.items():
            if histoire.roues.get(clef, None) is None:
                histoire.roues[clef] = valeur
        return histoire

    return emballé


def _effet_rien():
    """Ne fait rien."""

    def emballé(histoire: Histoire):
        return histoire

    return emballé


def _effet_ajoute(**ajouts):
    """Ajoute une valeur à une des roues."""

    def emballé(histoire):
        for key, value in ajouts.items():
            histoire.roues[key] += value
        return histoire

    return emballé


def _effet_et(*effets):
    """Applique plusieurs effets."""

    def emballé(histoire):
        for effet in effets:
            histoire = effet(histoire)
        return histoire

    return emballé


#: Effets pouvant être appliqués.
#:
#: .. automethod:: Effet.affecte
#: .. automethod:: Effet.affecteSiVide
#: .. automethod:: Effet.ajoute
#: .. automethod:: Effet.rien
#: .. automethod:: Effet.et
Effet = types.SimpleNamespace(
    rien=_effet_rien,
    ajoute=_effet_ajoute,
    affecte=_effet_affecte,
    affecteSiVide=_effet_affecte_si_vide,
    et=_effet_et,
)

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


def _condition_vrai(histoire: Histoire):  # pylint: disable=unused-argument
    """Cette condition est toujours vérifiée."""
    return True


def _condition_compte(
    valeur: str, inf: Number = float("-inf"), sup: Number = float("inf")
):
    """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"``.
    """

    def emballé(histoire):
        return inf <= operator.countOf(histoire.roues.values(), valeur) <= sup

    return emballé


def _condition_ou(*conditions):
    """L'une des conditions données en argument est vérifiée."""

    def emballé(histoire):
        return any(condition(histoire) for condition in conditions)

    return emballé


def _condition_et(*conditions):
    """Toutes les conditions données en argument sont vérifiées."""

    def emballé(histoire):
        return all(condition(histoire) for condition in conditions)

    return emballé


def _condition_roue(**roues):
    """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.
    """

    def emballé(histoire):
        for key, value in roues.items():
            try:
                if histoire.roues[key] != value:
                    return False
            except KeyError:
                return False
        return True

    return emballé


def _condition_non(condition):
    """Vérifie que la condition n'est pas satisfaite."""

    def emballé(histoire):
        return not condition(histoire)

    return emballé


def _condition_intervalle(roue, inf=-float("inf"), sup=float("inf")):
    def emballé(histoire):
        return inf <= histoire.roues[roue] <= sup

    return emballé


#: Conditions à vérifier pour être autorisé·e à faire ce choix
#:
#: .. automethod:: Condition.compte
#: .. automethod:: Condition.non
#: .. automethod:: Condition.ou
#: .. automethod:: Condition.et
#: .. automethod:: Condition.roue
#: .. automethod:: Condition.vrai
#: .. automethod:: Condition.intervalle
Condition = types.SimpleNamespace(
    compte=_condition_compte,
    non=_condition_non,
    ou=_condition_ou,
    et=_condition_et,
    roue=_condition_roue,
    vrai=_condition_vrai,
    intervalle=_condition_intervalle,
)


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

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: Iterable[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] @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 })"
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] 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], ) ) 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 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é ) ) 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 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