# 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