Skip to content

Meta #A — Méta-modèle traits/actions (models + migration) #113

Description

@RebelliousSmile

Type : feature · App : characters · Priorité : haute · Bloque : #114, #115, #117

Épique : Suddenly abandonne le catalogue FoundryVTT comme source de systèmes de jeu au profit d'un méta-modèle narratif interne unique (traits nommés à valeur + listes d'actions en texte). Cible : les MJ jouant déjà avec des systèmes narratifs à traits (PbtA, FATE, Mist), transposables quasi complètement. Les systèmes carac + compétence + dé vs difficulté basculent sur sheet_url externe.

Frontière d'architecture non négociable : Suddenly n'évalue rien. Les traits et conditions d'action sont affichés, jamais résolus. La fonction jeu reste côté CN. Suddenly est une membrane de consultation/partage, pas une surface de jeu.

Ordre de traitement recommandé : #113 (modèle) → #114 (form/UI) → #115 (admin) → #116 (retrait Foundry) → #117 (réécriture US) → #118 (extension JSON-LD, différée).

Description

Introduire trois modèles éditoriaux attachés au Character existant, permettant à un MJ de transposer une fiche narrative (livret PbtA, aspects FATE, tags Mist) sans dépendre d'un catalogue de systèmes.

  • TraitSet — lot nommé de traits (label libre : « Traits », « Aspects », « Thèmes »).
  • Trait — trait unique : name libre, value IntegerField nullable, note optionnel.
  • Action — action référençant un ou plusieurs traits : condition et outcome en texte affiché, jamais parsés.

Contraintes structurelles (pas seulement intention)

  • value est nullable (tags Mist sans valeur numérique) et non borné en base (le sélecteur −5/+5 vit dans l'UI, Meta #B — Formulaire de saisie traits/actions (UI + sélecteur −5/+5) #114). Aucun MinValueValidator/MaxValueValidator.
  • condition / outcome sont des TextField. Aucune expression parsable nulle part : cette absence est la garantie qu'aucun évaluateur ne peut être branché sans réécrire le modèle. Ne pas ajouter de champ « formule », « expression » ou « règle exécutable ».
  • Modèle autonome : aucune couture avec CN. CN et Suddenly ne partagent aujourd'hui que la consommation de Muses via le Hub. Pas de projection de traits, pas de champ withhold à surveiller.

Definition of Done

  • Trois modèles ajoutés dans suddenly/characters/models.py, héritant de BaseModel.
  • Migration makemigrations characters générée (non destructive : trois tables neuves, aucun modèle existant modifié).
  • related_name : character.trait_sets, trait_set.traits, trait_set.actions, trait.actions.
  • __str__ : Trait affiche name (+3) si valeur, name seul si null.
  • Index sur les FK (character, trait_set).
  • Tests : création lot + traits + actions, trait sans valeur, action multi-traits.

Code de référence

class TraitSet(BaseModel):
    character = models.ForeignKey(Character, on_delete=models.CASCADE, related_name="trait_sets")
    label = models.CharField(max_length=100, default="Traits",
        help_text="Free-form label (e.g. Traits, Aspects, Thèmes)")
    order = models.PositiveSmallIntegerField(default=0)

    class Meta:
        ordering = ["order", "created_at"]
        indexes = [models.Index(fields=["character"])]

    def __str__(self) -> str:
        return f"{self.label}{self.character.name}"


class Trait(BaseModel):
    trait_set = models.ForeignKey(TraitSet, on_delete=models.CASCADE, related_name="traits")
    name = models.CharField(max_length=200,
        help_text="Free-form trait name (e.g. Casse-cou, Sworn to the crown)")
    value = models.IntegerField(null=True, blank=True,
        help_text="Display value on the sheet. Nullable for valueless tags. Never evaluated.")
    note = models.TextField(blank=True, help_text="Optional fiction attached to the trait")
    order = models.PositiveSmallIntegerField(default=0)

    class Meta:
        ordering = ["order", "name"]
        indexes = [models.Index(fields=["trait_set"])]

    def __str__(self) -> str:
        return self.name if self.value is None else f"{self.name} ({self.value:+d})"


class Action(BaseModel):
    trait_set = models.ForeignKey(TraitSet, on_delete=models.CASCADE, related_name="actions")
    name = models.CharField(max_length=200,
        help_text="Free-form action name (e.g. Foncer dans le tas, Convaincre)")
    traits = models.ManyToManyField(Trait, blank=True, related_name="actions",
        help_text="One or more traits this action draws on")
    condition = models.TextField(blank=True,
        help_text="When this action applies — displayed text, never evaluated")
    outcome = models.TextField(blank=True,
        help_text="What the action produces — displayed text, never evaluated")
    order = models.PositiveSmallIntegerField(default=0)

    class Meta:
        ordering = ["order", "name"]
        indexes = [models.Index(fields=["trait_set"])]

    def __str__(self) -> str:
        return f"{self.name}{self.trait_set.label}"

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions