Beispiel #1
0
class Reaction(base.TableBase):
    """Table de données des réactions d'IA connues du bot.

    Les instances sont enregistrées via :meth:`\!addIA
    <.IA.GestionIA.GestionIA.addIA.callback>` et supprimées via
    :meth:`\!modifIA <.IA.GestionIA.GestionIA.modifIA.callback>`.
    """
    id = autodoc_Column(sqlalchemy.Integer(), primary_key=True,
        doc="Identifiant unique de la réaction, sans signification")
    reponse = autodoc_Column(sqlalchemy.String(2000), nullable=False,
        doc="Réponse, suivant le format (mini-langage) personnalisé "
            "(``\"txt <||> txt <&&> <##>react\"``)")

    # One-to-manys
    triggers = autodoc_OneToMany("Trigger", back_populates="reaction",
        doc="Déclencheurs associés")

    def __repr__(self):
        """Return repr(self)."""
        extract = self.reponse.replace('\n', ' ')[:15] + "..."
        return f"<Reaction #{self.id} ({extract})>"
Beispiel #2
0
class Joueur(base.TableBase):
    """Table de données des joueurs inscrits.

    Les instances de cette classe correspondent aux lignes du Tableau de
    bord ; elles sont crées par l'inscription (:func:`.inscription.main`)
    et synchronisées par :meth:`\!sync <.sync.Sync.Sync.sync.callback>`.
    """
    discord_id = autodoc_Column(sqlalchemy.BigInteger(), primary_key=True,
        autoincrement=False, doc="ID Discord du joueur")
    chan_id_ = autodoc_Column(sqlalchemy.BigInteger(), nullable=False,
        doc="ID du chan privé Discord du joueur. *(le ``_`` final indique "
            "que ce champ n'est pas synchnisé avec le Tableau de bord)*")

    nom = autodoc_Column(sqlalchemy.String(32), nullable=False,
        doc="Nom du joueur (demandé à l'inscription)")
    chambre = autodoc_Column(sqlalchemy.String(200),
        doc="Emplacement du joueur (demandé à l'inscription)")
    statut = autodoc_Column(sqlalchemy.Enum(Statut), nullable=False,
        default=Statut.vivant, doc="Statut RP")

    _role_slug = sqlalchemy.Column(sqlalchemy.ForeignKey("roles.slug"),
        nullable=False, default=lambda: config.default_role_slug)
    role = autodoc_ManyToOne("Role", back_populates="joueurs",
        doc="Rôle du joueur (défaut :meth:`.bdd.Role.default`)")

    _camp_slug = sqlalchemy.Column(sqlalchemy.ForeignKey("camps.slug"),
        nullable=False, default=lambda: config.default_camp_slug)
    camp = autodoc_ManyToOne("Camp", back_populates="joueurs",
        doc="Camp du joueur (défaut :meth:`.bdd.Camp.default`)")

    votant_village = autodoc_Column(sqlalchemy.Boolean(), nullable=False,
        default=True, doc="Le joueur participe aux votes du village ?")
    votant_loups = autodoc_Column(sqlalchemy.Boolean(), nullable=False,
        default=False, doc="Le joueur participe au vote des loups ?")
    role_actif = autodoc_Column(sqlalchemy.Boolean(), nullable=False,
        default=True, doc="Le joueur peut agir ? (pas chatgarouté...)")

    # One-to-manys
    actions = autodoc_OneToMany("Action", back_populates="joueur",
        doc="Actions pour ce joueur")
    candidharos = autodoc_OneToMany("CandidHaro", back_populates="joueur",
        doc="Candidatures et haros de/contre ce joueur")
    bouderies = autodoc_OneToMany("Bouderie", back_populates="joueur",
        doc="Appartenances aux boudoirs de ce joueur")
    ciblages = autodoc_DynamicOneToMany("Ciblage", back_populates="joueur",
        doc="Ciblages prenant ce joueur pour cible")

    def __repr__(self):
        """Return repr(self)."""
        return f"<Joueur #{self.discord_id} ({self.nom})>"

    def __str__(self):
        """Return str(self)."""
        return str(self.nom)

    @property
    def member(self):
        """discord.Member: Membre Discord correspondant à ce Joueur.

        Raises:
            ValueError: pas de membre correspondant
            ~ready_check.NotReadyError: bot non connecté
                (:obj:`.config.guild` vaut ``None``)
        """
        result = config.guild.get_member(self.discord_id)
        if not result:
            raise ValueError(f"Joueur.member : pas de membre pour `{self}` !")

        return result

    @property
    def private_chan(self):
        """discord.TextChannel: Channel privé (conversation bot) du joueur.

        Raises:
            ValueError: pas de channel correspondant
            ~ready_check.NotReadyError: bot non connecté
                (:obj:`.config.guild` vaut ``None``)
        """
        result = config.guild.get_channel(self.chan_id_)
        if not result:
            raise ValueError("Joueur.private_chan : "
                             f"pas de chan pour `{self}` !")

        return result

    @property
    def actions_actives(self):
        """Sequence[.bdd.Action]: Sous-ensemble de :attr:`actions` restreint
        aux actions actives.

        Élimine aussi les actions de vote (sans base).
        """
        return [ac for ac in self.actions if ac.active and not ac.vote]

    @property
    def boudoirs(self):
        """Sequence[.Boudoir]: Boudoirs où est ce jour (read-only)"""
        return [boud.boudoir for boud in self.bouderies]

    @hybrid_property
    def est_vivant(self):
        """:class:`bool` (instance)
        / :class:`sqlalchemy.sql.selectable.Exists` (classe):
        Le joueur est en vie ou MVtisé ?

        Raccourci pour
        ``joueur.statut in {Statut.vivant, Statut.MV}``

        Propriété hybride (voir :attr:`.Action.is_open` pour plus d'infos)
        """
        return (self.statut in {Statut.vivant, Statut.MV})

    @est_vivant.expression
    def est_vivant(cls):
        return cls.statut.in_({Statut.vivant, Statut.MV})

    @hybrid_property
    def est_mort(self):
        """:class:`bool` (instance)
        / :class:`sqlalchemy.sql.selectable.Exists` (classe):
        Le joueur est mort ?

        Raccourci pour ``joueur.statut == Statut.mort``

        Propriété hybride (voir :attr:`.Action.is_open` pour plus d'infos)
        """
        return (self.statut == Statut.mort)

    @classmethod
    def from_member(cls, member):
        """Récupère le Joueur (instance de BDD) lié à un membre Discord.

        Args:
            member (discord.Member): le membre concerné

        Returns:
            Joueur: Le joueur correspondant.

        Raises:
            ValueError: membre introuvable en base
            ~ready_check.NotReadyError: session non initialisée
                (:obj:`.config.session` vaut ``None``)
        """
        joueur = cls.query.get(member.id)
        if not joueur:
            raise ValueError("Joueur.from_member : "
                             f"pas de joueur en base pour `{member}` !")

        return joueur

    def action_vote(self, vote):
        """Retourne l'"action de vote" voulue pour ce joueur.

        Args:
            vote (.bdd.Vote): vote pour lequel récupérer l'action

        Returns:
            :class:`~bdd.Action`

        Raises:
            RuntimeError: action non existante
        """
        if not isinstance(vote, Vote):
            vote = Vote[vote]
        try:
            return next(act for act in self.actions if act.vote == vote)
        except StopIteration:
            raise RuntimeError(f"{self} : pas d'action de vote {vote.name} ! "
                               "!cparti a bien été appelé ?") from None
Beispiel #3
0
class Boudoir(base.TableBase):
    """Table de données des boudoirs sur le serveur.

    Les instances de cette classe sont crées, modifiées et supprimées par
    :meth:`\!boudoir <.chans.GestionChans.GestionChans.boudoir.callback>`.
    """
    chan_id = autodoc_Column(sqlalchemy.BigInteger(), primary_key=True,
        autoincrement=False, doc="ID Discord du salon")

    nom = autodoc_Column(sqlalchemy.String(32), nullable=False,
        doc="Nom du boudoir (demandé à la création)")
    ts_created = autodoc_Column(sqlalchemy.DateTime(), nullable=False,
        doc="Timestamp de la création")

    # One-to-manys
    bouderies = autodoc_OneToMany("Bouderie", back_populates="boudoir",
        doc="Appartenances à ce boudoir")

    def __repr__(self):
        """Return repr(self)."""
        return f"<Boudoir #{self.chan_id} ({self.nom})>"

    @property
    def chan(self):
        """discord.TextChannel: Salon Discord correspondant à ce boudoir.

        Raises:
            ValueError: pas de membre correspondant
            ~ready_check.NotReadyError: bot non connecté
                (:obj:`.config.guild` vaut ``None``)
        """
        result = config.guild.get_channel(self.chan_id)
        if not result:
            raise ValueError(f"Boudoir.chan : pas de chan pour {self} !")

        return result

    @property
    def joueurs(self):
        """Sequence[.Joueur]: Joueurs présents dans ce boudoir (read-only)"""
        return [boud.joueur for boud in self.bouderies]

    @property
    def gerant(self):
        """.bdd.Joueur: Membre du boudoir ayant les droits de gestion.

        Raises:
            ValueError: pas de membre avec les droits de gestion
        """
        try:
            return next(boud.joueur for boud in self.bouderies if boud.gerant)
        except StopIteration:
            raise ValueError(f"Pas de membre gérant le boudoir *{self.name}*")

    async def add_joueur(self, joueur, gerant=False):
        """Ajoute un joueur sur le boudoir.

        Crée la :class:`.Bouderie` correspondante et modifie les
        permissions du salon.

        Args:
            joueur (.Joueur): Le joueur à ajouter.
            gerant (bool): Si le joueur doit être ajouté avec les
                permissions de gérant.
        """
        now = datetime.datetime.now()
        Bouderie(boudoir=self, joueur=joueur, gerant=gerant,
                 ts_added=now, ts_promu=now if gerant else None).add()
        await self.chan.set_permissions(joueur.member, read_messages=True)

    async def remove_joueur(self, joueur):
        """Retire un joueur du boudoir.

        Supprime la :class:`.Bouderie` correspondante et modifie les
        permissions du salon.

        Args:
            joueur (.Joueur): Le joueur à ajouter.
        """
        Bouderie.query.filter_by(boudoir=self, joueur=joueur).one().delete()
        await self.chan.set_permissions(joueur.member, overwrite=None)

    @classmethod
    def from_channel(cls, channel):
        """Récupère le Boudoir (instance de BDD) lié à un salon Discord.

        Args:
            channel (discord.TextChannel): le salon concerné

        Returns:
            .Boudoir: Le salon correspondant.

        Raises:
            ValueError: boudoir introuvable en base
            ~ready_check.NotReadyError: session non initialisée
                (:obj:`.config.session` vaut ``None``)
        """
        boudoir = cls.query.get(channel.id)
        if not boudoir:
            raise ValueError("Boudoir.from_channel : "
                             f"pas de boudoir en base pour `{channel.mention}` !")

        return boudoir
Beispiel #4
0
class Action(base.TableBase):
    """Table de données des actions attribuées (liées à un joueur).

    Les instances doivent être enregistrées via
    :func:`.gestion_actions.add_action` et supprimées via
    :func:`.gestion_actions.delete_action`.
    """
    id = autodoc_Column(
        sqlalchemy.Integer(),
        primary_key=True,
        doc="Identifiant unique de l'action, sans signification")

    _joueur_id = sqlalchemy.Column(sqlalchemy.ForeignKey("joueurs.discord_id"),
                                   nullable=False)
    joueur = autodoc_ManyToOne("Joueur",
                               back_populates="actions",
                               doc="Joueur concerné")

    _base_slug = sqlalchemy.Column(sqlalchemy.ForeignKey("baseactions.slug"))
    base = autodoc_ManyToOne("BaseAction",
                             back_populates="actions",
                             nullable=True,
                             doc="Action de base (``None`` si action de vote)")

    vote = autodoc_Column(sqlalchemy.Enum(Vote),
                          doc="Si action de vote, vote concerné")

    active = autodoc_Column(
        sqlalchemy.Boolean(),
        nullable=False,
        default=True,
        doc="Si l'action est actuellement utilisable (False = archives)")

    cooldown = autodoc_Column(
        sqlalchemy.Integer(),
        nullable=False,
        default=0,
        doc="Nombre d'ouvertures avant disponiblité de l'action")
    charges = autodoc_Column(
        sqlalchemy.Integer(),
        doc="Nombre de charges restantes (``None`` si illimité)")

    # One-to-manys
    taches = autodoc_OneToMany("Tache",
                               back_populates="action",
                               doc="Tâches liées à cette action")

    utilisations = autodoc_DynamicOneToMany("Utilisation",
                                            back_populates="action",
                                            doc="Utilisations de cette action")

    def __init__(self, *args, **kwargs):
        """Initialize self."""
        n_args = (("base" in kwargs) + ("_base_slug" in kwargs) +
                  ("vote" in kwargs))
        if not n_args:
            raise ValueError("bdd.Action: 'base'/'_base_slug' or 'vote' "
                             "keyword-only argument must be specified")
        elif n_args > 1:
            raise ValueError("bdd.Action: 'base'/'_base_slug' and 'vote' "
                             "keyword-only argument cannot both be specified")
        super().__init__(*args, **kwargs)

    def __repr__(self):
        """Return repr(self)."""
        return f"<Action #{self.id} ({self.base or self.vote}/{self.joueur})>"

    @property
    def utilisation_ouverte(self):
        """:class:`~bdd.Utilisation` | ``None``: Utilisation de l'action
        actuellement ouverte.

        Vaut ``None`` si aucune action n'a actuellement l'état
        :attr:`~bdd.UtilEtat.ouverte` ou :attr:`~bdd.UtilEtat.remplie`.

        Raises:
            RuntimeError: plus d'une action a actuellement l'état
            :attr:`~bdd.UtilEtat.ouverte` ou :attr:`~bdd.UtilEtat.remplie`.
        """
        filtre = Utilisation.etat.in_({UtilEtat.ouverte, UtilEtat.remplie})
        try:
            return self.utilisations.filter(filtre).one_or_none()
        except sqlalchemy.orm.exc.MultipleResultsFound:
            raise ValueError(
                f"Plusieurs utilisations ouvertes pour `{self}` !")

    @property
    def derniere_utilisation(self):
        """:class:`~bdd.Utilisation` | ``None``:: Dernière utilisation de
        cette action (temporellement).

        Considère l'utilisation ouverte le cas échéant, sinon la
        dernière utilisation par timestamp de fermeture descendant
        (quelque soit son état, y comprs :attr:`~.bdd.UtilEtat.contree`).

        Vaut ``None`` si l'action n'a jamais été utilisée.

        Raises:
            RuntimeError: plus d'une action a actuellement l'état
            :attr:`~bdd.UtilEtat.ouverte` ou :attr:`~bdd.UtilEtat.remplie`.
        """
        return (self.utilisation_ouverte or self.utilisations.order_by(
            Utilisation.ts_close.desc()).first())

    @property
    def decision(self):
        """str: Description de la décision de la dernière utilisation.

        Considère l'utilisation ouverte le cas échéant, sinon la
        dernière utilisation par timestamp de fermeture descendant.

        Vaut :attr:`.Utilisation.decision`, ou ``"<N/A>"`` si il n'y a
        aucune utilisation de cette action.

        Raises:
            RuntimeError: plus d'une action a actuellement l'état
            :attr:`~bdd.UtilEtat.ouverte` ou :attr:`~bdd.UtilEtat.remplie`.
        """
        util = self.derniere_utilisation
        if util:
            return util.decision
        else:
            return "<N/A>"

    @hybrid_property
    def is_open(self):
        """:class:`bool` (instance)
        / :class:`sqlalchemy.sql.selectable.Exists` (classe):
        L'action est ouverte (l'utilisateur peut interagir) ?

        *I.e.* l'action a au moins une utilisation
        :attr:`~.bdd.UtilEtat.ouverte` ou :attr:`~.bdd.UtilEtat.remplie`.

        Propriété hybride (:class:`sqlalchemy.ext.hybrid.hybrid_property`) :

            - Sur l'instance, renvoie directement la valeur booléenne ;
            - Sur la classe, renvoie la clause permettant de déterminer
              si l'action est en attente.

        Examples::

            action.is_open          # bool
            Joueur.query.filter(Joueur.actions.any(Action.is_open)).all()
        """
        return bool(self.utilisations.filter(Utilisation.is_open).all())

    @is_open.expression
    def is_open(cls):
        return cls.utilisations.any(Utilisation.is_open)

    @hybrid_property
    def is_waiting(self):
        """:class:`bool` (instance)
        / :class:`sqlalchemy.sql.selectable.Exists` (classe):
        L'action est ouverte et aucune décision n'a été prise ?

        *I.e.* la clause a au moins une utilisation
        :attr:`~.bdd.UtilEtat.ouverte`.

        Propriété hybride (voir :attr:`.is_open` pour plus d'infos)
        """
        return bool(self.utilisations.filter(Utilisation.is_waiting).all())

    @is_waiting.expression
    def is_waiting(cls):
        return cls.utilisations.any(Utilisation.is_waiting)
Beispiel #5
0
class Utilisation(base.TableBase):
    """Table de données des utilisations des actions.

    Les instances sont enregistrées via :meth:`\!open
    <.open_close.OpenClose.OpenClose.open.callback>` ;
    elles n'ont pas vocation à être supprimées.
    """
    id = autodoc_Column(
        sqlalchemy.BigInteger(),
        primary_key=True,
        doc="Identifiant unique de l'utilisation, sans signification")

    _action_id = sqlalchemy.Column(sqlalchemy.ForeignKey("actions.id"),
                                   nullable=False)
    action = autodoc_ManyToOne("Action",
                               back_populates="utilisations",
                               doc="Action utilisée")

    etat = autodoc_Column(sqlalchemy.Enum(UtilEtat),
                          nullable=False,
                          default=UtilEtat.ouverte,
                          doc="État de l'utilisation")

    ts_open = autodoc_Column(sqlalchemy.DateTime(),
                             doc="Timestamp d'ouverture de l'utilisation")
    ts_close = autodoc_Column(sqlalchemy.DateTime(),
                              doc="Timestamp de fermeture de l'utilisation")
    ts_decision = autodoc_Column(
        sqlalchemy.DateTime(),
        doc="Timestamp du dernier remplissage de l'utilisation")

    # One-to-manys
    ciblages = autodoc_OneToMany("Ciblage",
                                 back_populates="utilisation",
                                 doc="Cibles désignées dans cette utilisation")

    def __repr__(self):
        """Return repr(self)."""
        return f"<Utilisation #{self.id} ({self.action}/{self.etat})>"

    def open(self):
        """Ouvre cette utilisation.

        Modifie son :attr:`etat`, définit :attr:`ts_open` au temps
        actuel, et update.
        """
        self.etat = UtilEtat.ouverte
        self.ts_open = datetime.datetime.now()
        self.update()

    def close(self):
        """Clôture cette utilisation.

        Modifie son :attr:`etat`, définit :attr:`ts_close` au temps
        actuel, et update.
        """
        if self.etat == UtilEtat.remplie:
            self.etat = UtilEtat.validee
        else:
            self.etat = UtilEtat.ignoree
        self.ts_close = datetime.datetime.now()
        self.update()

    def ciblage(self, slug):
        """Renvoie le ciblage de base de slug voulu.

        Args:
            slug (str): Doit correspondre à un des slugs des bases
                des :attr:`ciblages` de l'utilisation.

        Returns:
            :class:`.bdd.Ciblage`

        Raises:
            ValueError: slug non trouvé dans les :attr:`ciblages`
        """
        try:
            return next(cib for cib in self.ciblages if cib.base.slug == slug)
        except StopIteration:
            raise ValueError(
                f"{self} : pas de ciblage de slug '{slug}'") from None

    @property
    def cible(self):
        """:class:`~bdd.Joueur` | ``None``: Joueur ciblé par l'utilisation,
        si applicable.

        Cet attribut n'est accessible que si l'utilisation est d'un vote
        ou d'une action définissant un et une seul ciblage de type
        :attr:`~bdd.CibleType.joueur`, :attr:`~bdd.CibleType.vivant`
        ou :attr:`~bdd.CibleType.mort`.

        Vaut ``None`` si l'utilisation a l'état
        :attr:`~bdd.UtilEtat.ouverte` ou :attr:`~bdd.UtilEtat.ignoree`.

        Raises:
            ValueError: l'action ne remplit pas les critères évoqués
            ci-dessus
        """
        if self.action.vote:
            # vote : un BaseCiblage implicite de type CibleType.vivants
            return self.ciblages[0].joueur if self.ciblages else None
        else:
            base_ciblages = self.action.base.base_ciblages
            bc_joueurs = [
                bc for bc in base_ciblages if bc.type in
                [CibleType.joueur, CibleType.vivant, CibleType.mort]
            ]
            if len(bc_joueurs) != 1:
                raise ValueError(f"L'utilisation {self} n'a pas une et "
                                 "une seule cible de type joueur")

            base_ciblage = bc_joueurs[0]
            try:
                ciblage = next(cib for cib in self.ciblages
                               if cib.base == base_ciblage)
            except StopIteration:
                return None  # Pas de ciblage fait

            return ciblage.joueur

    @property
    def decision(self):
        """str: Description de la décision de cette utilisation.

        Complète le template de :.bdd.BaseAction.decision_format` avec
        les valeurs des ciblages de l'utilisation.

        Vaut ``"Ne rien faire"`` si l'utilisation n'a pas de ciblages,
        et :attr:`.cible` dans le cas d'un vote.
        """
        if not self.action.base:
            return str(self.cible)

        if not self.ciblages:
            return "Ne rien faire"

        template = self.action.base.decision_format
        data = {
            ciblage.base.slug: ciblage.valeur_descr
            for ciblage in self.ciblages
        }
        try:
            return template.format(**data)
        except KeyError:
            return template

    @hybrid_property
    def is_open(self):
        """:class:`bool` (instance)
        / :class:`sqlalchemy.sql.selectable.Exists` (classe):
        L'utilisation est ouverte (l'utilisateur peut interagir) ?

        Raccourci pour
        ``utilisation.etat in {UtilEtat.ouverte, UtilEtat.remplie}``

        Propriété hybride (voir :attr:`.Action.is_open` pour plus d'infos)
        """
        return (self.etat in {UtilEtat.ouverte, UtilEtat.remplie})

    @is_open.expression
    def is_open(cls):
        return cls.etat.in_({UtilEtat.ouverte, UtilEtat.remplie})

    @hybrid_property
    def is_waiting(self):
        """:class:`bool` (instance)
        / :class:`sqlalchemy.sql.selectable.Exists` (classe):
        L'utilisation est ouverte et aucune décision n'a été prise ?

        Raccourci pour ``utilisation.etat == UtilEtat.ouverte``

        Propriété hybride (voir :attr:`.Action.is_open` pour plus d'infos)
        """
        return (self.etat == UtilEtat.ouverte)

    @hybrid_property
    def is_filled(self):
        """:class:`bool` (instance)
        / :class:`sqlalchemy.sql.selectable.Exists` (classe):
        L'utilisation est remplie (l'utilisateur a interagi avec) ?

        Raccourci pour
        ``utilisation.etat in {UtilEtat.remplie, UtilEtat.validee,
        UtilEtat.contree}``

        Propriété hybride (voir :attr:`.Action.is_open` pour plus d'infos)
        """
        return (self.etat
                in {UtilEtat.remplie, UtilEtat.validee, UtilEtat.contree})

    @is_filled.expression
    def is_filled(cls):
        return cls.etat.in_(
            {UtilEtat.remplie, UtilEtat.validee, UtilEtat.contree})
Beispiel #6
0
class Role(base.TableBase):
    """Table de données des rôles.

    Cette table est remplie automatiquement à partir du Google Sheet
    "Rôles et actions" par la commande
    :meth:`\!fillroles <.sync.Sync.Sync.fillroles.callback>`.
    """
    slug = autodoc_Column(sqlalchemy.String(32), primary_key=True,
        doc="Identifiant unique du rôle")

    prefixe = autodoc_Column(sqlalchemy.String(8), nullable=False, default="",
        doc="Article du nom du rôle (``\"Le \"``, ``\"La \"``, ``\"L'\"``...)")
    nom = autodoc_Column(sqlalchemy.String(32), nullable=False,
        doc="Nom (avec casse et accents) du rôle")

    _camp_slug = sqlalchemy.Column(sqlalchemy.ForeignKey("camps.slug"),
        nullable=False)
    camp = autodoc_ManyToOne("Camp", back_populates="roles",
        doc="Camp auquel ce rôle est affilié à l'origine\n\n (On peut avoir "
            "``joueur.camp != joueur.role.camp`` si damnation, passage MV...)")

    actif = autodoc_Column(sqlalchemy.Boolean(), nullable=False, default=True,
        doc="Rôle actif ? (affiché dans la liste des rôles, etc)")

    description_courte = autodoc_Column(sqlalchemy.String(140), nullable=False,
        default="",
        doc="Description du rôle en une ligne")
    description_longue = autodoc_Column(sqlalchemy.String(1800),
        nullable=False, default="",
        doc="Règles et background complets du rôle")

    # to-manys
    joueurs = autodoc_OneToMany("Joueur", back_populates="role",
        doc="Joueurs ayant ce rôle")
    ciblages = autodoc_DynamicOneToMany("Ciblage", back_populates="role",
        doc="Ciblages prenant ce rôle pour cible")
    base_actions = autodoc_ManyToMany("BaseAction", secondary=_baseaction_role,
        back_populates="roles",
        doc="Modèles d'actions associées")

    def __repr__(self):
        """Return repr(self)."""
        return f"<Role '{self.slug}' ({self.prefixe}{self.nom})>"

    def __str__(self):
        """Return str(self)."""
        return self.nom_complet

    @property
    def nom_complet(self):
        """str: Préfixe + nom du rôle"""
        return f"{self.prefixe}{self.nom}"

    @property
    def embed(self):
        """discord.Embed: Embed Discord présentant le rôle et ses actions."""
        emb = discord.Embed(
            title=f"**{self.nom_complet}** – {self.description_courte}",
            description=self.description_longue
        )
        if (emoji := self.camp.discord_emoji_or_none):
            emb.set_thumbnail(url=emoji.url)
        for ba in self.base_actions:
            emb.add_field(name=f"{config.Emoji.action} Action : {ba.slug}",
                          value=ba.temporalite)
        return emb
Beispiel #7
0
class BaseAction(base.TableBase):
    """Table de données des actions définies de base (non liées à un joueur).

    Cette table est remplie automatiquement à partir du Google Sheet
    "Rôles et actions" par la commande :meth:`\!fillroles
    <.remplissage_bdd.RemplissageBDD.RemplissageBDD.fillroles.callback>`.
    """
    slug = autodoc_Column(sqlalchemy.String(32), primary_key=True,
        doc="Identifiant unique de l'action")

    trigger_debut = autodoc_Column(sqlalchemy.Enum(ActionTrigger),
        nullable=False, default=ActionTrigger.perma,
        doc="Mode de déclenchement de l'ouverture de l'action")
    trigger_fin = autodoc_Column(sqlalchemy.Enum(ActionTrigger),
        nullable=False, default=ActionTrigger.perma,
        doc="Mode de déclenchement de la clôture de l'action")
    instant = autodoc_Column(sqlalchemy.Boolean(), nullable=False,
        default=False,
        doc="L'action est instantannée (conséquence dès la prise de décision)"
            " ou non (conséquence à la fin du créneau d'action)")

    heure_debut = autodoc_Column(sqlalchemy.Time(),
        doc="Si :attr:`.trigger_debut` vaut "
            ":attr:`~ActionTrigger.temporel`, l'horaire associé")
    heure_fin = autodoc_Column(sqlalchemy.Time(),
        doc="Si :attr:`.trigger_fin` vaut\n"
            "- :attr:`~ActionTrigger.temporel` : l'horaire associé ;\n"
            "- :attr:`~ActionTrigger.delta`, l'intervalle associé")

    base_cooldown = autodoc_Column(sqlalchemy.Integer(), nullable=False,
        default=0,
        doc="Temps de rechargement entre deux utilisations du pouvoir "
            "(``0`` si pas de cooldown)")
    base_charges = autodoc_Column(sqlalchemy.Integer(),
        doc="Nombre de charges initiales du pouvoir (``None`` si illimité)")
    refill = autodoc_Column(sqlalchemy.String(32), nullable=False, default="",
        doc="Évènements pouvant recharger l'action, séparés par des virgules "
            "(``\"weekends\"``, ``\"forgeron\"``, ``\"rebouteux\"``...)")

    lieu = autodoc_Column(sqlalchemy.String(100),
        doc="*Attribut informatif, non exploité dans la version actuelle "
            "(Distance/Physique/Lieu/Contact/Conditionnel/None/Public)*")
    interaction_notaire = autodoc_Column(sqlalchemy.String(100),
        doc="*Attribut informatif, non exploité dans la version actuelle "
            "(Oui/Non/Conditionnel/Potion/Rapport ; None si récursif)*")
    interaction_gardien = autodoc_Column(sqlalchemy.String(100),
        doc="*Attribut informatif, non exploité dans la version actuelle "
            "(Oui/Non/Conditionnel/Taverne/Feu/MaisonClose/Précis/"
            "Cimetière/Loups ; None si récursif)*")
    mage = autodoc_Column(sqlalchemy.String(100),
        doc="*Attribut informatif, non exploité dans la version actuelle "
            "(Oui/Non/Changement de cible/...)*")

    decision_format = autodoc_Column(sqlalchemy.String(200),
        nullable=False, default="",
        doc="Description des utilisations de ces action, sous forme de "
            "texte formaté avec les noms des :attr:`.BaseCiblage.slug` "
            "entre accolades (exemple : ``Tuer {cible}``)")

    # -to-manys
    actions = autodoc_OneToMany("Action", back_populates="base",
        doc="Actions déroulant de cette base")
    base_ciblages = autodoc_OneToMany("BaseCiblage",
        back_populates="base_action", cascade="all, delete-orphan",
        order_by="BaseCiblage.prio",
        doc="Ciblages de ce modèle d'action (triés par priorité)")
    roles = autodoc_ManyToMany("Role", secondary=_baseaction_role,
        back_populates="base_actions",
        doc="Rôles ayant cette action de base")

    def __repr__(self):
        """Return repr(self)."""
        return f"<BaseAction '{self.slug}'>"

    def __str__(self):
        """Return str(self)."""
        return str(self.slug)

    @property
    def temporalite(self):
        """str: Phrase décrivant le mode d'utilisation / timing de l'action."""
        def _time_to_heure(tps):
            if not tps:
                return ""
            if tps.hour == 0:
                return f"{tps.minute} min"
            if tps.minute > 0:
                return f"{tps.hour}h{tps.minute:02}"
            return f"{tps.hour}h"

        rep = ""
        # Périodicté
        if self.trigger_debut == ActionTrigger.perma:
            rep += "N'importe quand"
        elif self.trigger_debut == ActionTrigger.start:
            rep += "Au lancement de la partie"
        elif self.trigger_debut == ActionTrigger.mort:
            rep += "À la mort"
        else:
            if self.base_cooldown:
                rep += f"Tous les {self.base_cooldown + 1} jours "
            else:
                rep += "Tous les jours "

            # Fenêtre
            if self.trigger_debut == ActionTrigger.mot_mjs:
                rep += "à l'annonce des résultats du vote"
            elif self.trigger_debut == ActionTrigger.open_cond:
                rep += "pendant le vote condamné"
            elif self.trigger_debut == ActionTrigger.open_maire:
                rep += "pendant le vote pour le maire"
            elif self.trigger_debut == ActionTrigger.open_loups:
                rep += "pendant le vote des loups"
            elif self.trigger_debut == ActionTrigger.close_cond:
                rep += "à la fermeture du vote condamné"
            elif self.trigger_debut == ActionTrigger.close_maire:
                rep += "à la fermeture du vote pour le maire"
            elif self.trigger_debut == ActionTrigger.close_loups:
                rep += "à la fermeture du vote des loups"
            elif self.trigger_debut == ActionTrigger.temporel:
                if self.trigger_fin == ActionTrigger.temporel:
                    rep += f"de {_time_to_heure(self.heure_debut)}"
                else:
                    rep += f"à {_time_to_heure(self.heure_debut)}"

        # Fermeture
        if self.trigger_fin == ActionTrigger.delta:
            rep += f" – {_time_to_heure(self.heure_fin)} pour agir"
        elif self.trigger_fin == ActionTrigger.temporel:
            rep += f" à {_time_to_heure(self.heure_fin)}"

        # Autres caractères
        if self.instant:
            rep += f" (conséquence instantanée)"
        if self.base_charges:
            rep += f" – {self.base_charges} fois"
        if "weekends" in self.refill:
            rep += f" par semaine"

        return rep
Beispiel #8
0
class Camp(base.TableBase):
    """Table de données des camps, publics et secrets.

    Cette table est remplie automatiquement à partir du Google Sheet
    "Rôles et actions" par la commande
    :meth:`\!fillroles <.sync.Sync.Sync.fillroles.callback>`.
    """
    slug = autodoc_Column(sqlalchemy.String(32), primary_key=True,
        doc="Identifiant unique du camp")

    nom = autodoc_Column(sqlalchemy.String(32), nullable=False,
        doc="Nom (affiché) du camp")
    description = autodoc_Column(sqlalchemy.String(1000), nullable=False,
        default="",
        doc="Description (courte) du camp")

    public = autodoc_Column(sqlalchemy.Boolean(), nullable=False, default=True,
        doc="L'existance du camp (et des rôles liés) est connue de tous ?")

    emoji = autodoc_Column(sqlalchemy.String(32),
        doc="Nom de l'emoji associé au camp (doit être le nom d'un "
            "emoji existant sur le serveur)")

    # One-to-manys
    joueurs = autodoc_OneToMany("Joueur", back_populates="camp",
        doc="Joueurs appartenant à ce camp")
    roles = autodoc_OneToMany("Role", back_populates="camp",
        doc="Rôles affiliés à ce camp de base")
    ciblages = autodoc_DynamicOneToMany("Ciblage", back_populates="camp",
        doc="Ciblages prenant ce camp pour cible")

    def __repr__(self):
        """Return repr(self)."""
        return f"<Camp '{self.slug}' ({self.nom})>"

    def __str__(self):
        """Return str(self)."""
        return str(self.nom)

    @property
    def discord_emoji(self):
        """discord.Emoji: Emoji Discord correspondant à ce camp

        Raises:
            ValueError: :attr:`.emoji` non défini ou manquant sur le serveur
            ~ready_check.NotReadyError: bot non connecté
                (:obj:`.config.guild` vaut ``None``)
        """
        if not self.emoji:
            raise ValueError(f"{self}.emoji non défini !")

        try:
            return next(e for e in config.guild.emojis if e.name == self.emoji)
        except StopIteration:
            raise ValueError(f"Pas d'emoji :{self.emoji}: "
                             "sur le serveur !") from None

    @property
    def discord_emoji_or_none(self):
        """:class:`discord.Emoji` | ``None``: :attr:`.discord_emoji` si défini

        Raises:
            ~ready_check.NotReadyError: bot non connecté
                (:obj:`.config.guild` vaut ``None``)
        """
        try:
            return self.discord_emoji
        except ValueError:
            return None

    @classmethod
    def default(cls):
        """Retourne le camp par défaut (celui avant attribution).

        Warning:
            Un camp de :attr:`.slug` :obj:`.config.default_camp_slug`
            doit être défini en base.

        Returns:
            ~bdd.Camp

        Raises:
            ValueError: camp introuvable en base
            RuntimeError: session non initialisée
                (:obj:`.config.session` vaut ``None``)
        """
        slug = config.default_camp_slug
        camp = cls.query.get(slug)
        if not camp:
            raise ValueError(
                "Camp par défaut (de slug "
                f"lgrez.config.default_camp_slug = \"{slug}\") non "
                "défini (dans le GSheet Rôles et actions) ou non "
                f"chargé (`!fillroles`) !"
            )
        return camp