Exemple #1
0
class Bouderie(base.TableBase):
    """Table de données des appartenances aux boudoirs du serveur.

    Table d'association entre :class:`.Joueur` et :class:`.Boudoir`.

    Les instances de cette classe sont crées, modifiées et supprimées par
    :meth:`\!boudoir <.chans.GestionChans.GestionChans.boudoir.callback>`.
    """
    id = autodoc_Column(sqlalchemy.Integer(), primary_key=True,
        doc="Identifiant unique de la bouderie, sans signification")

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

    _boudoir_id = sqlalchemy.Column(sqlalchemy.ForeignKey("boudoirs.chan_id"),
        nullable=False)
    boudoir = autodoc_ManyToOne("Boudoir", back_populates="bouderies",
        doc="Boudoir concerné")

    gerant = autodoc_Column(sqlalchemy.Boolean(), nullable=False,
        default=False,
        doc="Si le joueur a les droits de gestion du boudoir")

    ts_added = autodoc_Column(sqlalchemy.DateTime(), nullable=False,
        doc="Timestamp de l'ajout du joueur au boudoir")
    ts_promu = autodoc_Column(sqlalchemy.DateTime(),
        doc="Timestamp de la promotion en gérant, le cas échéant")

    def __repr__(self):
        """Return repr(self)."""
        return f"<Bouderie #{self.id} ({self.joueur}/{self.boudoir})>"
Exemple #2
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})>"
Exemple #3
0
class Trigger(base.TableBase):
    """Table de données des mots et expressions déclenchant l'IA du bot.

    Les instances sont enregistrées via :meth:`\!addIA
    <.IA.GestionIA.GestionIA.addIA.callback>` ou :meth:`\!modifIA
    <.IA.GestionIA.GestionIA.modifIA.callback>` et supprimées via
    :meth:`\!modifIA <.IA.GestionIA.GestionIA.modifIA.callback>`.
    """
    id = autodoc_Column(sqlalchemy.Integer(), primary_key=True,
        doc="Identifiant unique du déclencheur, sans signification")
    trigger = autodoc_Column(sqlalchemy.String(500), nullable=False,
        doc="Mots-clés / expressions")

    _reac_id = sqlalchemy.Column(sqlalchemy.ForeignKey("reactions.id"),
        nullable=False)
    reaction = autodoc_ManyToOne("Reaction", back_populates="triggers",
        doc="Réaction associée")

    def __repr__(self):
        """Return repr(self)."""
        extract = self.trigger.replace('\n', ' ')[:15] + "..."
        return f"<Trigger #{self.id} ({extract})>"
Exemple #4
0
class BaseCiblage(base.TableBase):
    """Table de données des modèles de ciblages des actions de base.

    [TODO] Cette table est remplie automatiquement à partir du Google Sheet
    "Rôles et actions" par la commande :meth:`\!fillroles
    <.remplissage_bdd.RemplissageBDD.RemplissageBDD.fillroles.callback>`.
    """
    _id = autodoc_Column(sqlalchemy.Integer(), primary_key=True,
        doc="Identifiant unique du modèle de ciblage, sans signification")

    _baseaction_slug = sqlalchemy.Column(sqlalchemy.ForeignKey(
        "baseactions.slug"), nullable=False)
    base_action = autodoc_ManyToOne("BaseAction",
        back_populates="base_ciblages",
        doc="Modèle d'action définissant ce ciblage")

    slug = autodoc_Column(sqlalchemy.String(32), nullable=False,
        default="unique",
        doc="Identifiant de la cible dans le modèle d'action")
    type = autodoc_Column(sqlalchemy.Enum(CibleType), nullable=False,
        default=CibleType.texte,
        doc="Message d'interaction au joueur au moment de choisir la cible")

    prio = autodoc_Column(sqlalchemy.Integer(), nullable=False, default=1,
        doc="Ordre (relatif) d'apparition du ciblage lors du ``!action`` "
        "\n\nSi deux ciblages ont la même priorité, ils seront considérés "
        "comme ayant une signification symmétrique (notamment, si "
        ":attr:`doit_changer` vaut ``True``, tous les membres du groupe "
        "devront changer) ; l'ordre d'apparition dépend alors de leur "
        ":attr:`slug`, par ordre alphabétique (``cible1`` < ``cible2``).")

    phrase = autodoc_Column(sqlalchemy.String(1000), nullable=False,
        default="Cible ?",
        doc="Message d'interaction au joueur au moment de choisir la cible")

    obligatoire = autodoc_Column(sqlalchemy.Boolean(), nullable=False,
        default=True,
        doc="Si le ciblage doit obligatoirement être renseigné")
    doit_changer = autodoc_Column(sqlalchemy.Boolean(), nullable=False,
        default=False,
        doc="Si la cible doit changer d'une utilisation à l'autre.\n\n"
        "Si la dernière utilisation est ignorée ou contrée, il n'y a "
        "pas de contrainte.")

    # one-to-manys
    ciblages = autodoc_DynamicOneToMany("Ciblage", back_populates="base",
        doc="Ciblages déroulant de cette base")

    def __repr__(self):
        """Return repr(self)."""
        return f"<BaseCiblage #{self._id} ({self.base_action}/{self.slug})>"
Exemple #5
0
class CandidHaro(base.TableBase):
    """Table de données des candidatures et haros en cours #PhilippeCandidHaro.

    Les instances sont enregistrées via :meth:`\!haro
    <.actions_publiques.ActionsPubliques.ActionsPubliques.haro.callback>`
    / :meth:`\!candid
    <.actions_publiques.ActionsPubliques.ActionsPubliques.candid.callback>`
    et supprimées via :meth:`\!wipe
    <.actions_publiques.ActionsPubliques.ActionsPubliques.wipe.callback>`.
    """
    id = autodoc_Column(sqlalchemy.Integer(), primary_key=True,
        doc="Identifiant unique du candidharo, sans signification")

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

    type = autodoc_Column(sqlalchemy.Enum(CandidHaroType), nullable=False,
        doc="Haro ou candidature ?")

    def __repr__(self):
        """Return repr(self)."""
        return f"<CandidHaro #{self.id} ({self.joueur}/{self.type})>"
Exemple #6
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
Exemple #7
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
Exemple #8
0
class Tache(base.TableBase):
    """Table de données des tâches planifiées du bot.

    Les instances doivent être enregistrées via :meth:`.add`
    et supprimées via :func:`.delete`.
    """
    id = autodoc_Column(
        sqlalchemy.Integer(),
        primary_key=True,
        doc="Identifiant unique de la tâche, sans signification")
    timestamp = autodoc_Column(sqlalchemy.DateTime(),
                               nullable=False,
                               doc="Moment où exécuter la tâche")
    commande = autodoc_Column(
        sqlalchemy.String(2000),
        nullable=False,
        doc="Texte à envoyer via le webhook (généralement une commande)")

    _action_id = sqlalchemy.Column(sqlalchemy.ForeignKey("actions.id"),
                                   nullable=True)
    action = autodoc_ManyToOne(
        "Action",
        back_populates="taches",
        nullable=True,
        doc="Si la tâche est liée à une action, action concernée")

    def __repr__(self):
        """Return repr(self)."""
        return f"<Tache #{self.id} ({self.commande})>"

    @property
    def handler(self):
        """asyncio.TimerHandle: Représentation dans le bot de la tâche.

        Proxy pour :attr:`config.bot.tasks[self.id] <.LGBot.tasks>`,
        en lecture, écriture et suppression (``del``).

        Raises:
            RuntimeError: tâche non enregistrée dans le bot.
        """
        try:
            return config.bot.tasks[self.id]
        except KeyError:
            raise RuntimeError(f"Tâche {self} non enregistrée dans le bot !")

    @handler.setter
    def handler(self, value):
        if self.id is None:
            raise RuntimeError("Tache.handler: Tache.id non défini (commit ?)")
        config.bot.tasks[self.id] = value

    @handler.deleter
    def handler(self):
        try:
            del config.bot.tasks[self.id]
        except KeyError:
            pass

    async def send_webhook(self, tries=0):
        """Exécute la tâche (coroutine programmée par :meth:`execute`).

        Envoie un webhook (:obj:`.config.webhook`) de contenu
        :attr:`commande`.

        Si une exception quelconque est levée par l'envoi du webhook,
        re-programme l'exécution de la tâche (:meth:`execute`) 2 secondes
        après ; ce jusqu'à 5 fois, après quoi un message d'alerte est
        envoyé dans :attr:`.config.Channel.logs`.

        Si aucune exception n'est levée (succès), supprime la tâche.

        Args:
            tries (int): Numéro de l'essai d'envoi actuellement en cours
        """
        try:
            await config.webhook.send(self.commande)
        except Exception as exc:
            if tries < 5:
                # On réessaie
                config.loop.call_later(2, self.execute, tries + 1)
            else:
                await config.Channel.logs.send(
                    f"{config.Role.mj.mention} ALERT: impossible  "
                    f"d'envoyer un webhook (5 essais, erreur : "
                    f"```{type(exc).__name__}: {exc})```\n"
                    f"Commande non envoyée : `{self.commande}`")
        else:
            self.delete()

    def execute(self, tries=0):
        """Exécute la tâche planifiée (méthode appellée par la loop).

        Programme :meth:`send_webhook` pour exécution immédiate.

        Args:
            tries (int): Numéro de l'essai d'envoi actuellement en cours,
                passé à :meth:`send_webhook`.
        """
        asyncio.create_task(self.send_webhook(tries=tries))
        # programme la coroutine pour exécution immédiate

    def register(self):
        """Programme l'exécution de la tâche dans la loop du bot."""
        now = datetime.datetime.now()
        delay = (self.timestamp - now).total_seconds()
        TH = config.loop.call_later(delay, self.execute)
        # Programme la tâche (appellera tache.execute() à timestamp)
        self.handler = TH  # TimerHandle, pour pouvoir cancel

    def cancel(self):
        """Annule et nettoie la tâche planifiée (sans la supprimer en base).

        Si la tâche a déjà été exécutée, ne fait que nettoyer le handler.
        """
        try:
            self.handler.cancel()  # Annule la task (objet TimerHandle)
            # (pas d'effet si la tâche a déjà été exécutée)
        except RuntimeError:  # Tache non enregistrée
            pass
        else:
            del self.handler

    def add(self, *other):
        """Enregistre la tâche sur le bot et en base.

        Globalement équivalent à un appel à :meth:`.register` (pour
        chaque élément le cas échéant) avant l'ajout en base habituel
        (:meth:`TableBase.add <.bdd.base.TableBase.add>`).

        Args:
            \*other: autres instances à ajouter dans le même commit,
                éventuellement.
        """
        super().add(*other)  # Enregistre tout en base

        self.register()  # Enregistre sur le bot
        for item in other:  # Les autres aussi
            item.register()

    def delete(self, *other):
        """Annule la tâche planifiée et la supprime en base.

        Globalement équivalent à un appel à :meth:`.cancel` (pour
        chaque élément le cas échéant) avant la suppression en base
        habituelle (:meth:`TableBase.add <.bdd.base.TableBase.add>`).

        Args:
            \*other: autres instances à supprimer dans le même commit,
                éventuellement.
        """
        self.cancel()  # Annule la tâche
        for item in other:  # Les autres aussi
            item.cancel()

        super().delete(*other)  # Supprime tout en base
Exemple #9
0
class Ciblage(base.TableBase):
    """Table de données des cibles désignées dans les utilisations d'actions.

    Les instances sont enregistrées via :meth:`\!action
    <.voter_agir.VoterAgir.VoterAgir.action.callback>` ;
    elles n'ont pas vocation à être supprimées.
    """
    id = autodoc_Column(
        sqlalchemy.Integer(),
        primary_key=True,
        doc="Identifiant unique du ciblage, sans signification")

    _base_id = sqlalchemy.Column(sqlalchemy.ForeignKey("baseciblages._id"))
    base = autodoc_ManyToOne(
        "BaseCiblage",
        back_populates="ciblages",
        nullable=True,
        doc="Modèle de ciblage (lié au modèle d'action). Vaut ``None`` pour "
        "un ciblage de vote")

    _utilisation_id = sqlalchemy.Column(
        sqlalchemy.ForeignKey("utilisations.id"), nullable=False)
    utilisation = autodoc_ManyToOne("Utilisation",
                                    back_populates="ciblages",
                                    doc="Utilisation où ce ciblage a été fait")

    _joueur_id = sqlalchemy.Column(sqlalchemy.ForeignKey("joueurs.discord_id"),
                                   nullable=True)
    joueur = autodoc_ManyToOne(
        "Joueur",
        back_populates="ciblages",
        nullable=True,
        doc="Joueur désigné, si ``base.type`` vaut "
        ":attr:`~.bdd.CibleType.joueur`, :attr:`~.bdd.CibleType.vivant` "
        "ou :attr:`~.bdd.CibleType.mort`")

    _role_slug = sqlalchemy.Column(sqlalchemy.ForeignKey("roles.slug"),
                                   nullable=True)
    role = autodoc_ManyToOne("Role",
                             back_populates="ciblages",
                             nullable=True,
                             doc="Rôle désigné, si ``base.type`` vaut "
                             ":attr:`~.bdd.CibleType.role`")

    _camp_slug = sqlalchemy.Column(sqlalchemy.ForeignKey("camps.slug"),
                                   nullable=True)
    camp = autodoc_ManyToOne("Camp",
                             back_populates="ciblages",
                             nullable=True,
                             doc="Camp désigné, si ``base.type`` vaut "
                             ":attr:`~.bdd.CibleType.camp`")

    booleen = autodoc_Column(
        sqlalchemy.Boolean(),
        doc="Valeur, si ``base.type`` vaut :attr:`~.bdd.CibleType.booleen`")
    texte = autodoc_Column(
        sqlalchemy.String(1000),
        doc="Valeur, si ``base.type`` vaut :attr:`~.bdd.CibleType.texte`")

    def __repr__(self):
        """Return repr(self)."""
        return f"<Ciblage #{self.id} ({self.base}/{self.utilisation})>"

    @property
    def _val_attr(self):
        """Nom de l'attribut stockant la valeur du ciblage"""
        if (not self.base  # vote
                or self.base.type
                in {CibleType.joueur, CibleType.vivant, CibleType.mort}):
            return "joueur"
        elif self.base.type == CibleType.role:
            return "role"
        elif self.base.type == CibleType.camp:
            return "camp"
        elif self.base.type == CibleType.booleen:
            return "booleen"
        elif self.base.type == CibleType.texte:
            return "texte"
        else:
            raise ValueError(f"Ciblage de type inconnu : {self.base.type}")

    @property
    def valeur(self):
        """:class:`~bdd.Joueur` | :class:`~bdd.Role`| :class:`~bdd.Camp`
        | :class:`bool` | :class:`str`: Valeur du ciblage, selon son type.

        Propriété en lecture et écriture.

        Raises:
            ValueError: ciblage de type inconnu
        """
        return getattr(self, self._val_attr)

    @valeur.setter
    def valeur(self, value):
        setattr(self, self._val_attr, value)

    @property
    def valeur_descr(self):
        """str: Description de la valeur du ciblage.

        Si :attr:`valeur` vaut ``None``, renvoie ``<N/A>``

        Raises:
            ValueError: ciblage de type inconnu
        """
        if self.valeur is None:
            return "<N/A>"

        if (not self.base  # vote
                or self.base.type
                in {CibleType.joueur, CibleType.vivant, CibleType.mort}):
            return self.joueur.nom
        elif self.base.type == CibleType.role:
            return self.role.nom_complet
        elif self.base.type == CibleType.camp:
            return self.camp.nom
        elif self.base.type == CibleType.booleen:
            return "Oui" if self.booleen else "Non"
        else:
            return self.texte
Exemple #10
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)
Exemple #11
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})
Exemple #12
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
Exemple #13
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
Exemple #14
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