Procházet zdrojové kódy

Statuts plus variés : les deux derniers types exclus

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet před 3 dny
rodič
revize
9a328518ff
4 změnil soubory, kde provedl 30 přidání a 8 odebrání
  1. 2 0
      .env.exemple
  2. 1 1
      README.md
  3. 18 6
      bot/presence.py
  4. 9 1
      changelog.txt

+ 2 - 0
.env.exemple

@@ -54,6 +54,8 @@ PRESENCE=on
 # 15 à 45 min en ligne, 30 à 90 min de pensée, 10 à 60 min d'absence, 30 à 90 min en Ne pas déranger.
 # Chances de chaque statut à la fin du précédent. Vide = valeurs ci-dessous.
 # Un statut absent de la liste garde sa valeur par défaut ; 0 le désactive.
+# Pour varier, les deux derniers statuts tirés ne peuvent pas revenir, et celui d'avant revient
+# deux fois moins souvent.
 PRESENCE_WEIGHTS=anime=20,musique=20,pensee=10,jeu=20,en_ligne=20,ne_pas_deranger=5,absent=5
 # Heures pendant lesquelles le bot est absent (de 2 h à 6 h), ou off
 PRESENCE_NIGHT=2-6

+ 1 - 1
README.md

@@ -27,7 +27,7 @@ Seul le nom du fichier reste ensuite en mémoire : le bot se souvient qu'il l'a
 
 ### Vie sur le serveur
 
-- **Statut Discord** (`PRESENCE=on`) : sous son pseudo, le bot regarde quelques épisodes d'un anime (top 500 d'AniList), écoute quelques morceaux (tops Deezer), joue à un jeu de la boutique Steam, affiche une pensée du moment, ou passe absent ou en « Ne pas déranger » avec une raison qu'il écrit lui-même. Il dort la nuit (`PRESENCE_NIGHT`) et sait ce qu'il est en train de faire quand on lui en parle. Après un redémarrage, il reprend son statut là où il en serait s'il ne s'était pas arrêté.
+- **Statut Discord** (`PRESENCE=on`) : sous son pseudo, le bot regarde quelques épisodes d'un anime (top 500 d'AniList), écoute quelques morceaux (tops Deezer), joue à un jeu de la boutique Steam, affiche une pensée du moment, ou passe absent ou en « Ne pas déranger » avec une raison qu'il écrit lui-même. Pour varier, un type de statut ne revient pas avant au moins deux autres. Il dort la nuit (`PRESENCE_NIGHT`) et sait ce qu'il est en train de faire quand on lui en parle. Après un redémarrage, il reprend son statut là où il en serait s'il ne s'était pas arrêté.
 - **Absences** : absent, il ne répond qu'à son retour ; en « Ne pas déranger », au bout de 5 à 15 minutes. S'il s'en va en pleine conversation, il prévient d'abord son salon. Les messages en attente sont gardés même après un redémarrage.
 - **Emojis du serveur** : le bot connaît automatiquement tous les emojis du serveur, animés compris, et peut les utiliser dans ses réponses. Un emoji ajouté ou retiré est pris en compte tout de suite.
 - **Stickers et emojis seuls** : à un sticker seul, il répond par un sticker du serveur ; à un emoji seul, par un emoji du serveur.

+ 18 - 6
bot/presence.py

@@ -41,6 +41,9 @@ UTC = timezone.utc  # datetime.UTC n'existe qu'à partir de Python 3.11
 SOURCES_REFRESH_INTERVAL = 24 * 3600  # secondes
 CUSTOM_STATUS_MAX_LENGTH = 128  # limite de Discord
 RECENT_CUSTOM_STATUSES = 4  # statuts personnalisés récents, à ne pas répéter
+# Variété : chances des derniers types de statut tirés, du plus récent au plus ancien (0 : exclu).
+# Les deux derniers ne peuvent pas revenir, le troisième revient deux fois moins souvent.
+RECENT_KIND_FACTORS = (0, 0, 0.5)
 GAME_ATTEMPTS = 5  # jeux essayés avant d'abandonner (fiche introuvable, jeu pour adultes…)
 DND_REPLY_MINUTES = (5, 15)  # délai de réponse en « Ne pas déranger »
 
@@ -190,6 +193,7 @@ class PresenceManager:
         self.step_end = datetime.min.replace(tzinfo=UTC)  # fin de l'étape en cours (UTC)
         self.status_end: datetime | None = None  # fin du statut en cours, toutes étapes comprises
         self.recent_custom: deque[str] = deque(maxlen=RECENT_CUSTOM_STATUSES)
+        self.recent_kinds: deque[Kind] = deque(maxlen=len(RECENT_KIND_FACTORS))  # derniers types tirés
         self.state_file = settings.history_file.with_name("presence_state.json")
         self._tasks: list[asyncio.Task] = []
         self._changed = asyncio.Event()  # déclenché à chaque changement de statut (voir wait_turn)
@@ -216,6 +220,7 @@ class PresenceManager:
             "status_end": self.status_end.isoformat() if self.status_end else None,
             "program": [[status.to_dict(), seconds] for status, seconds in self.program],
             "recent_custom": list(self.recent_custom),
+            "recent_kinds": [kind.value for kind in self.recent_kinds],
         }
         tmp = self.state_file.with_suffix(".json.tmp")
         try:
@@ -231,7 +236,8 @@ class PresenceManager:
         try:
             data = json.loads(self.state_file.read_text(encoding="utf-8"))
             self.recent_custom.extend(data.get("recent_custom", []))
-            status = Status.from_dict(data["status"])
+            self.recent_kinds.extend(Kind(value) for value in data.get("recent_kinds", []))
+            status =Status.from_dict(data["status"])
             program = [(Status.from_dict(step), float(seconds)) for step, seconds in data.get("program", [])]
             saved_at = datetime.fromisoformat(data["saved_at"])
             step_end = datetime.fromisoformat(data["step_end"])
@@ -399,7 +405,8 @@ class PresenceManager:
         if now >= self.step_end:
             if not self.program:
                 self.program = await self.draw()
-                total = sum(seconds for _, seconds in self.program)
+                self.recent_kinds.append(self.program[0][0].kind)  # celui affiché (en ligne si un jeu échoue…)
+                total =sum(seconds for _, seconds in self.program)
                 self.status_end = now + timedelta(seconds=total)
                 logger.info("Nouveau statut pour %s.", format_duration(total * self.settings.presence_speed))
             status, seconds = self.program.pop(0)
@@ -415,10 +422,15 @@ class PresenceManager:
     async def draw(self) -> list[Step]:
         """Tire un nouveau statut et son programme (durées divisées par PRESENCE_SPEED)."""
         available = {kind: weight for kind, weight in self.weights.items() if weight > 0 and self.has_source(kind)}
-        # Jamais deux fois le même statut à la suite, sauf s'il n'y a pas d'autre choix.
-        if len(available) > 1:
-            available.pop(self.status.kind, None)
-        kind = random.choices(list(available), weights=list(available.values()))[0] if available else Kind.ONLINE
+        # Les derniers types tirés reviennent moins, ou pas (voir RECENT_KIND_FACTORS), tant qu'il reste un autre choix.
+        for recent, factor in zip(reversed(self.recent_kinds), RECENT_KIND_FACTORS):
+            if recent not in available:
+                continue
+            if factor == 0 and len(available) > 1:
+                del available[recent]
+            elif factor > 0:
+                available[recent] *= factor
+        kind =random.choices(list(available), weights=list(available.values()))[0] if available else Kind.ONLINE
         speed = self.settings.presence_speed
 
         if kind is Kind.ANIME:

+ 9 - 1
changelog.txt

@@ -32,13 +32,21 @@ Nouveautés
   personnalisés ne sont pas répétés. Le statut est gardé dans
   presence_state.json, à côté de l'historique.
 - /skip : abandonne le statut Discord en cours (l'anime ou la playlist
-  entière) et en tire un nouveau tout de suite, jamais du même type.
+  entière) et en tire un nouveau tout de suite, selon les mêmes règles.
   Réservé aux modérateurs et administrateurs, comme /reset ; seul celui
   qui lance la commande voit le nouveau statut et sa durée. Si Rika
   était absente et passe en ligne, elle répond aussitôt aux messages en
   attente ; si elle s'éloigne en pleine conversation, elle prévient son
   salon. Sans effet la nuit, et absente avec PRESENCE=off. La commande
   est envoyée à Discord au premier démarrage de la 5.9.
+- Statuts plus variés : un nouveau statut ne peut plus être du même type
+  que les deux derniers (avant, seul le dernier était exclu), et le type
+  d'avant revient deux fois moins souvent. Les suites comme anime /
+  musique / anime deviennent impossibles : un type vu dans les 3
+  derniers statuts revient dans 15 % des tirages au lieu de 37 %, et les
+  chances de PRESENCE_WEIGHTS restent presque respectées. Si trop de
+  statuts sont désactivés pour respecter la règle, elle s'assouplit.
+  Les derniers types tirés sont gardés dans presence_state.json.
 
 
 Version 5.8