Code source de lgrez.bdd.model_jeu

"""lg-rez / bdd / Modèle de données

Déclaration de toutes les tables et leurs colonnes

"""

import discord
import sqlalchemy

from lgrez import config
from lgrez.bdd import base
from lgrez.bdd.base import (autodoc_Column, autodoc_ManyToOne,
                            autodoc_OneToMany, autodoc_DynamicOneToMany,
                            autodoc_ManyToMany)
from lgrez.bdd.enums import ActionTrigger, CibleType


# Tables de jonction (pour many-to-manys)

_baseaction_role = sqlalchemy.Table('_baseactions_roles',
    base.TableBase.metadata,
    sqlalchemy.Column('_role_slug', sqlalchemy.ForeignKey('roles.slug')),
    sqlalchemy.Column('_baseaction_slug',
                      sqlalchemy.ForeignKey('baseactions.slug')),
    sqlalchemy.schema.UniqueConstraint('_role_slug', '_baseaction_slug'),
)


# Tables de données

[docs]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
[docs] @classmethod def default(cls): """Retourne le rôle par défaut (celui avant attribution). Warning: Un rôle de :attr:`.slug` :obj:`.config.default_role_slug` doit être défini en base. Returns: ~bdd.Role Raises: ValueError: rôle introuvable en base RuntimeError: session non initialisée (:obj:`.config.session` vaut ``None``) """ slug = config.default_role_slug role = cls.query.get(slug) if not role: raise ValueError( "Rôle par défaut (de slug " f"`lgrez.config.default_role_slug = \"{slug}\"`) non " "défini (dans le GSheet Rôles et actions) " "ou non chargé (`!fillroles`) !" ) return role
[docs]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 @property def embed(self): """discord.Embed: Embed Discord présentant le camp.""" emb = discord.Embed( title=f"Camp : {self.nom}", description=self.description, color=0x64b9e9, ) if (emoji := self.discord_emoji_or_none): emb.set_image(url=emoji.url) return emb
[docs] @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
[docs]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
[docs]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})>"