Просмотр исходного кода

Statuts plus variés : les deux derniers types exclus

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 3 дней назад
Родитель
Сommit
9a328518ff
4 измененных файлов с 30 добавлено и 8 удалено
  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.
 # 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.
 # 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.
 # 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
 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
 # Heures pendant lesquelles le bot est absent (de 2 h à 6 h), ou off
 PRESENCE_NIGHT=2-6
 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
 ### 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.
 - **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.
 - **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.
 - **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
 SOURCES_REFRESH_INTERVAL = 24 * 3600  # secondes
 CUSTOM_STATUS_MAX_LENGTH = 128  # limite de Discord
 CUSTOM_STATUS_MAX_LENGTH = 128  # limite de Discord
 RECENT_CUSTOM_STATUSES = 4  # statuts personnalisés récents, à ne pas répéter
 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…)
 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 »
 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.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.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_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.state_file = settings.history_file.with_name("presence_state.json")
         self._tasks: list[asyncio.Task] = []
         self._tasks: list[asyncio.Task] = []
         self._changed = asyncio.Event()  # déclenché à chaque changement de statut (voir wait_turn)
         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,
             "status_end": self.status_end.isoformat() if self.status_end else None,
             "program": [[status.to_dict(), seconds] for status, seconds in self.program],
             "program": [[status.to_dict(), seconds] for status, seconds in self.program],
             "recent_custom": list(self.recent_custom),
             "recent_custom": list(self.recent_custom),
+            "recent_kinds": [kind.value for kind in self.recent_kinds],
         }
         }
         tmp = self.state_file.with_suffix(".json.tmp")
         tmp = self.state_file.with_suffix(".json.tmp")
         try:
         try:
@@ -231,7 +236,8 @@ class PresenceManager:
         try:
         try:
             data = json.loads(self.state_file.read_text(encoding="utf-8"))
             data = json.loads(self.state_file.read_text(encoding="utf-8"))
             self.recent_custom.extend(data.get("recent_custom", []))
             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", [])]
             program = [(Status.from_dict(step), float(seconds)) for step, seconds in data.get("program", [])]
             saved_at = datetime.fromisoformat(data["saved_at"])
             saved_at = datetime.fromisoformat(data["saved_at"])
             step_end = datetime.fromisoformat(data["step_end"])
             step_end = datetime.fromisoformat(data["step_end"])
@@ -399,7 +405,8 @@ class PresenceManager:
         if now >= self.step_end:
         if now >= self.step_end:
             if not self.program:
             if not self.program:
                 self.program = await self.draw()
                 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)
                 self.status_end = now + timedelta(seconds=total)
                 logger.info("Nouveau statut pour %s.", format_duration(total * self.settings.presence_speed))
                 logger.info("Nouveau statut pour %s.", format_duration(total * self.settings.presence_speed))
             status, seconds = self.program.pop(0)
             status, seconds = self.program.pop(0)
@@ -415,10 +422,15 @@ class PresenceManager:
     async def draw(self) -> list[Step]:
     async def draw(self) -> list[Step]:
         """Tire un nouveau statut et son programme (durées divisées par PRESENCE_SPEED)."""
         """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)}
         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
         speed = self.settings.presence_speed
 
 
         if kind is Kind.ANIME:
         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
   personnalisés ne sont pas répétés. Le statut est gardé dans
   presence_state.json, à côté de l'historique.
   presence_state.json, à côté de l'historique.
 - /skip : abandonne le statut Discord en cours (l'anime ou la playlist
 - /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
   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
   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
   é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
   attente ; si elle s'éloigne en pleine conversation, elle prévient son
   salon. Sans effet la nuit, et absente avec PRESENCE=off. La commande
   salon. Sans effet la nuit, et absente avec PRESENCE=off. La commande
   est envoyée à Discord au premier démarrage de la 5.9.
   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
 Version 5.8