dd-timer/algorithmes.md
POL Mickaël 2893013093 docs: README, CLAUDE.md, changelog, plans de conception
- README : fonctionnalités, installation, build, tests (302 + 20 E2E),
  couverture 94%, workflow mise à jour latest.yml, changelog v1.1.6
- CLAUDE.md : règles de collaboration, architecture, conventions
- Plans de conception : DDD, electron-updater, accouplement, toast

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 05:43:38 +02:00

470 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Algorithmes de calcul — Minuteur Dragodinde
Ce document décrit tous les algorithmes utilisés dans l'application, expliqués simplement.
---
## 1. Système de tiers des jauges
Chaque jauge (baffeur, caresseur, foudroyeur, abreuvoir, dragofesse, mangeoire) a un **niveau** entre 0 et 100 000.
Ce niveau détermine un **tier** qui fixe la vitesse de conversion :
| Niveau de jauge | Tier | Taux (pts / 10 sec) |
|-----------------|------|---------------------|
| 0 40 000 | 1 | 10 |
| 40 001 70 000 | 2 | 20 |
| 70 001 90 000 | 3 | 30 |
| 90 001 100 000 | 4 | 40 |
**Principe** : La jauge se **vide** en donnant des points à la DD. Plus la jauge est haute, plus elle se vide vite (tier élevé = plus de points par tick).
Un "tick" = 10 secondes de timer.
---
## 2. Calcul des points gagnés dans le temps — `gainedIn(lvl, sec)`
**Question** : "Si ma jauge démarre au niveau `lvl` et tourne pendant `sec` secondes, combien de points ma DD gagne-t-elle ?"
**Algorithme** :
1. On part du haut (tier 4) et on descend
2. Pour chaque palier, on calcule combien de ticks on peut consommer avant de passer au palier en-dessous
3. On additionne les points de chaque palier traversé
**Exemple** : Jauge à 95 000, timer 60 sec (= 6 ticks)
- Tier 4 (90k100k) : 5 000 pts dispo ÷ 40/tick = 125 ticks possibles → on en utilise 6 → gain = 6 × 40 = **240 pts**
- Jauge restante : 95 000 - 240 = 94 760
La jauge descend au fur et à mesure qu'elle donne des points. Quand elle passe sous un seuil de tier, le taux ralentit.
---
## 3. Temps pour gagner X points — `timeToGain(lvl, pts)`
**Question** : "Combien de temps faut-il pour gagner `pts` points depuis une jauge au niveau `lvl` ?"
C'est l'inverse de `gainedIn`. On parcourt les paliers du haut vers le bas :
1. Pour chaque tier, combien de points peut-on donner avant de descendre au palier suivant ?
2. On prend le minimum entre les points disponibles et les points restants à donner
3. On calcule le temps correspondant : `ceil(points / taux) × 10 sec`
Si la jauge se vide complètement avant d'atteindre l'objectif → retourne `Infinity` (impossible).
**Cas "vidange complète"** : `timeToGain(lvl, lvl)` donne le temps pour vider entièrement la jauge. C'est cette formule qu'utilisent à la fois l'affichage "Vide en" et le timer XP quand la cible est hors de portée d'une seule charge.
---
## 4. Niveau de jauge après X secondes — `gaugeAfter(lvl, sec)`
**Question** : "Si ma jauge démarre à `lvl` et tourne `sec` secondes, à quel niveau sera-t-elle ?"
Même logique que `gainedIn`, mais au lieu de compter les points donnés, on soustrait directement du niveau de la jauge.
---
## 5. Temps écoulé — `elapsed` et `elapsedLive`
### `elapsed(timer)` — temps figé pour l'affichage
Calcule les secondes écoulées depuis le démarrage du timer d'un enclos, en excluant les pauses. **Se fige** quand le timer est en pause ou terminé :
```
Si en cours : (maintenant - démarrage - temps_en_pause) / 1000
Si en pause : (moment_pause - démarrage - temps_en_pause) / 1000
Si non démarré : 0
```
Utilisé pour : l'affichage "Temps écoulé", le dashboard.
### `elapsedLive(enc)` — temps réel après complétion automatique
Retourne un temps qui **continue de progresser en temps réel** même après que la session s'est terminée automatiquement. Cela permet aux jauges de continuer à se vider en arrière-plan après l'alarme.
```
Si enc.alerted['__done__'] est positionné :
→ (Date.now() - startTime - pausedMs) / 1000 (jamais figé)
Sinon :
→ elapsed(enc.timer) (comportement normal, fige sur pause)
```
**`enc.alerted['__done__']`** est posé par la commande `complete-timer` quand toutes les cibles sont atteintes. Ce flag distingue une "fin naturelle de session" (jauges continuent) d'une "pause manuelle" (jauges figées).
Utilisé pour : tous les calculs de jauges dans `computeGaugeLive`, `enclosGaugeCurGl`, `calcSerenEtaLive`, `calcLevelEtaLive`.
---
## 6. Gel de jauge au cap absolu des stats et calcul par segments — `computeGaugeState`
### 6.1 Gel au cap absolu
Quand une **stat** atteint sa **limite absolue** (sérénité ±5000, endurance/maturité/amour 20 000), la jauge correspondante **s'arrête de se vider** — il n'y a plus rien à donner à la DD.
**Points jusqu'au cap** (`ptsToAbsCap`) :
- Stat normale : `dir>0 ? (statMax - startSt) : (startSt - statMin)` — la jauge continue **même après la cible**, jusqu'au cap absolu de la stat
- XP / mangeoire : `xpForLevel(200) - xpForLevel(niveauDépart)` — la jauge gèle uniquement au niveau 200, **jamais à la cible XP**
**Comportement après la cible** : les jauges ne s'arrêtent PAS quand la cible d'une DD est atteinte. Elles continuent de se vider jusqu'à ce que la stat atteigne son cap absolu (ex: sérénité à -5000, même si la cible était -60). Cela garantit que l'affichage reste cohérent et que les DDs continuent de progresser en fond.
**Affichage global de la jauge de stat (enclos)** : la jauge de stat se fige quand la **dernière** DD atteint son cap absolu. La mangeoire continue de se vider jusqu'au niveau 200.
### 6.2 Calcul par segments avec recharges — `computeGaugeState(startGl, recharges, ptsAllowed, el)`
Quand le joueur recharge une jauge en cours de session, le calcul se découpe en **segments** :
```
Segment 1 : startGl → recharge[0].atSec (ou fin du timer si pas de recharge)
Segment 2 : recharge[0].level → recharge[1].atSec
...
Segment N : recharge[N-1].level → el
```
Pour chaque segment :
1. Calculer la durée du segment
2. Calculer les points gagnés dans ce segment avec `gainedIn`
3. Vérifier si le cap (`ptsAllowed`) est atteint → si oui, calculer le moment exact du gel et stopper
4. Si non, passer au segment suivant avec le nouveau niveau de jauge après recharge
**Retourne** : `{ gained, curGl, effectiveEl }`
- `gained` : points totaux accumulés sur tous les segments
- `curGl` : niveau de jauge au moment du gel (ou à `el` si pas de gel)
- `effectiveEl` : temps réel de fonctionnement (≤ el, limité par le gel)
---
## 7. Calcul unifié d'une jauge — `computeGaugeLive(enc, dd, gid, el, started)`
C'est le **cœur du système**. Pour chaque DD et chaque jauge active, il calcule en temps réel :
- La stat estimée actuelle
- Si la cible est atteinte
- Le % de progression
- Le countdown restant
> **Note sur le paramètre `el`** : bien que la signature accepte un paramètre `el` (elapsed), il est **ignoré en interne**. La fonction appelle systématiquement `elapsedLive(enc)` pour ses calculs, ce qui garantit que les jauges continuent de progresser après la fin de session. Le paramètre `el` est conservé dans la signature pour la compatibilité des appels depuis `enclosGlobalState`.
### Pour les jauges normales (sérénité, endurance, maturité, amour) :
1. On récupère le **snapshot** de la jauge et de la stat au moment du démarrage du timer
2. On appelle `computeGaugeState(startGl, recharges, ptsToAbsCap, elLive)` pour obtenir les points gagnés en tenant compte des recharges et du gel
3. On applique la direction (`dir`) :
- `dir = +1` (caresseur, foudroyeur, abreuvoir, dragofesse) : stat monte
- `dir = -1` (baffeur) : stat descend
4. On clamp la stat dans ses bornes (ex: sérénité entre -5000 et +5000)
5. On calcule le temps total et le countdown via `timeToGain`
### Pour la mangeoire (XP) — même modèle que les autres jauges :
La mangeoire se vide exactement comme les autres jauges (même tiers, même dégression). L'XP suit le même algorithme :
1. XP gagnée = via `computeGaugeState` (tient compte des recharges et du gel à niveau 200)
2. XP nécessaire = `xpForLevel(cible) - xpForLevel(départ)`
3. Niveau estimé = `levelFromXp(xpDépart + xpGagnée)`
4. Countdown = `timeToGain(curGl, Math.min(xpRestante, curGl))` — si la cible dépasse la capacité de la jauge, on affiche le temps de vidange complète
**Cible XP par défaut** : si `dd.levelTarget === null`, on cible le niveau 200.
**Exemple** : mangeoire à 85 000 (tier 3) pendant 7 000 sec (700 ticks) :
- 500 ticks à 30 XP/tick (tier 3, 70k→85k) = 15 000 XP, jauge → 70 000
- 200 ticks à 20 XP/tick (tier 2) = 4 000 XP, jauge → 66 000
- Total = **19 000 XP** (contre 21 000 XP avec un taux fixe erroné)
---
## 8. Table d'XP et niveaux
### Table XP_RAW
Dictionnaire de 200 entrées : `niveau → XP cumulatif total`.
Exemples :
- Niveau 1 → 0 XP
- Niveau 10 → 809 XP
- Niveau 50 → 34 365 XP
- Niveau 100 → 172 668 XP
- Niveau 200 → 867 582 XP
Les valeurs sont **cumulatives** (pas incrémentales).
### `xpForLevel(lvl)` : Retourne l'XP cumulatif pour atteindre le niveau `lvl`.
### `levelFromXp(xp)` : Retourne le plus haut niveau atteint avec `xp` points d'XP cumulatifs. Parcourt la table de 200 à 1 pour trouver le seuil.
---
## 9. ETA Sérénité — `calcSerenEta` / `calcSerenEtaLive`
### Version statique (`calcSerenEta`)
1. Calcule `diff = cible - sérénité actuelle`
2. Si `diff > 0` → besoin du caresseur (monte la sérénité)
3. Si `diff < 0` → besoin du baffeur (baisse la sérénité)
4. Temps = `timeToGain(niveauJauge, |diff|)`
### Version live (`calcSerenEtaLive`)
1. Utilise `computeGaugeState` pour obtenir la sérénité estimée en temps réel (avec recharges et gel)
2. Recalcule le diff depuis cette estimation
3. Calcule le temps restant avec les points encore à parcourir
---
## 10. ETA Niveau — `calcLevelEta` / `calcLevelEtaLive`
### Version statique (`calcLevelEta`)
1. XP nécessaire = `xpForLevel(cible) - xpForLevel(niveauActuel)`
2. Cible = `dd.levelTarget ?? 200`
3. Temps = `timeToGain(niveauMangeoire, Math.min(xpNécessaire, niveauMangeoire))`
### Version live (`calcLevelEtaLive`)
1. Cible = `dd.levelTarget ?? 200`
2. XP gagnée via `computeGaugeState` (avec recharges et gel)
3. Niveau estimé = `levelFromXp(xpDépart + xpGagnée)`
4. XP restante = `xpForLevel(cible) - xpForLevel(départ) - xpGagnée`
5. Countdown = `timeToGain(curGl, Math.min(xpRestante, curGl))`
- Si XP restante ≤ capacité de la jauge : temps pour atteindre la cible
- Sinon : temps de vidange complète (cohérent avec "Vide en")
---
## 11. Countdown global d'un enclos — `enclosGlobalState(enc)`
**Question** : "Dans combien de temps TOUTES les DD de cet enclos auront atteint TOUTES leurs cibles ?"
1. Pour chaque DD × chaque jauge active → appeler `computeGaugeLive`
2. Prendre le **maximum** de tous les countdowns = le plus long à terminer
3. Compter combien de DD ont TOUTES leurs cibles atteintes (`ddDone`)
4. `allDone = true` si **toutes les jauges actives** de **toutes les DDs** ont atteint leur cible
**Règle d'alarme unique** : la session se termine (`complete-timer`) une seule fois, au timer le plus long. Si un enclos a baffeur + mangeoire, l'alarme ne sonne que quand les DEUX cibles (sérenité ET niveau XP) sont atteintes pour toutes les DDs. Il n'y a pas d'alarme intermédiaire quand une seule cible est atteinte.
**Prévisualisation avant démarrage** : la fonction calcule même quand le timer n'est pas démarré (`started = false`, `el = 0`). L'"Alarme dans" et les timers DD se mettent à jour en temps réel dès que le joueur saisit une valeur de jauge ou une cible — sans avoir à démarrer le timer.
## 11 bis. Vérification globale de complétion — `checkAllEnclosCompletion()` dans `App`
La vérification de complétion ne dépend pas de la vue active ni du focus de la fenêtre.
**Pourquoi pas dans la boucle rAF ?** `requestAnimationFrame` se **suspend** quand l'application Electron perd le focus OS (ex : l'utilisateur alt-tab vers une autre application). Si la détection était dans la boucle rAF, la notification ne sonnerait jamais quand l'utilisateur est hors de l'app.
**Solution** : `checkAllEnclosCompletion()` tourne dans un `window.setInterval(..., 1000)` indépendant, démarré dans `App.render()` et nettoyé dans `App.destroy()`. `setInterval` continue de s'exécuter même quand la fenêtre est en arrière-plan.
**Algorithme** :
1. Parcourt **tous** les enclos en cours (`summary.running = true`)
2. Pour chacun, récupère l'état complet et appelle `enclosGlobalState`
3. Si `allDone = true`, exécute `complete-timer` → alarme + notification immédiate
**Double protection** : `EnclosView.update()` appelle aussi `complete-timer` quand `allDone && running` (quand l'utilisateur est sur l'enclos). Le handler `complete-timer` possède un guard `if (!enc.timer.running || enc.alerted['__done__']) return` pour éviter un double déclenchement.
---
## 12. Recharge de jauge en cours de session
Le joueur peut **recharger une jauge pendant que le timer tourne** (ex : remplir la mangeoire à mi-session).
### Enregistrement (`recharge-gauge`)
- Stocke `{ atSec: elapsed(), level: newLevel }` dans `timer.gaugeRecharges[gid]`
- Plusieurs recharges s'accumulent dans un tableau
- Le `reset-timer` vide tous les tableaux de recharge
### Impact sur le calcul
`computeGaugeState` segmente automatiquement le calcul entre chaque recharge :
- Segment 1 depuis `startGl` jusqu'à la première recharge
- Segment 2 depuis le nouveau niveau jusqu'à la recharge suivante (ou la fin)
- etc.
Le gel au cap absolu est vérifié dans chaque segment : si la stat atteint son cap avant la prochaine recharge, le gel est précis à la seconde près.
### Affichage
Les inputs de jauge affichent une bordure verte (classe `.gauge-inp-recharge`) pendant que le timer tourne, indiquant que toute nouvelle saisie sera interprétée comme une recharge.
---
## 13. Mises à jour en temps réel des inputs
Tous les champs de saisie (niveaux de jauges, stats des DD, cibles) déclenchent une mise à jour de l'état à **chaque frappe** via un listener `input`, en plus du `blur` final. Cela permet :
- L'"Alarme dans" de se recalculer instantanément pendant la saisie
- Les timers DD (XP, sérénité, etc.) de se mettre à jour à la volée
- Une expérience cohérente avant ET pendant le timer
Exception : les recharges de jauge ne sont déclenchées que sur `blur`/`Enter` (pas sur chaque frappe) pour éviter d'enregistrer des recharges partielles.
---
## 14. Arbre de réapprovisionnement — `calcAppro()`
**Question** : "Pour produire Q exemplaires de la race X, de quelles races et en quelles quantités ai-je besoin ?"
### Principe : décomposition récursive par génération
Chaque race de génération ≥ 2 est produite par le croisement de 2 races parentes (table `BREEDING_RECIPES`).
**Algorithme** :
1. On part de la race cible et de la quantité voulue
2. Pour chaque génération (de la plus haute à gen 2) :
- Pour chaque race nécessaire à cette génération :
- Calculer le nombre de couples nécessaires (voir §14.1)
- Ajouter les parents nécessaires (chacun × nombre de couples) dans le pool
3. Les races gen 1 restantes = matières premières
### 14.1 Mécanisme du reproducteur
Un reproducteur est une DD réutilisable : elle peut faire plusieurs bébés.
```
Si 2×R ≥ Q → couples = ceil(Q / 2)
Sinon → couples = Q - R
```
`R` = nombre de reproducteurs, `Q` = quantité nécessaire.
---
## 15. Calcul d'inventaire avec contraintes ♂/♀ — `calcInventaire()`
**Question** : "Avec mon stock actuel de DD (mâles et femelles), quels croisements puis-je réaliser ?"
### Modèle de données
Chaque race dans l'inventaire : `{ m: mâles, f: femelles, n: neutres }`
### Algorithme : round-robin par génération
Pour chaque génération (2 → 10) :
1. Lister tous les croisements possibles à cette génération
2. **Boucle round-robin** : tant qu'au moins un croisement est possible :
- Vérifier qu'on a 1 mâle-capable chez A ET 1 femelle-capable chez B (ou l'inverse)
- Consommer les parents (priorité au stock réel m/f, puis les neutres n)
- Ajouter 1 neutre (`n++`) à la race du bébé produit
### Priorité de consommation
```
takeMale : si m > 0 → m--, sinon n--
takeFemale : si f > 0 → f--, sinon n--
```
---
## 16. Constantes des stats
| Stat | Min | Max | Jauge(s) associée(s) |
|-----------|--------|--------|----------------------|
| Sérénité | -5 000 | 5 000 | Baffeur (↓), Caresseur (↑) |
| Endurance | 0 | 20 000 | Foudroyeur (↑) |
| Maturité | 0 | 20 000 | Abreuvoir (↑) |
| Amour | 0 | 20 000 | Dragofesse (↑) |
| Niveau/XP | 1 | 200 | Mangeoire (↑) |
---
## 17. Système de snapshots
Quand le timer démarre (démarrage initial uniquement, pas lors d'une reprise de pause) :
- `snapGauges` : niveaux de toutes les jauges actives au moment du démarrage
- `snapStats[dd.id]` : stats de chaque DD au moment du démarrage
- `gaugeRecharges` : réinitialisé à `{}`
Tous les calculs utilisent ces snapshots comme point de départ. Une **reprise de pause** accumule uniquement `pausedMs` sans toucher aux snapshots.
---
## 18. Flux de session — enchaînement des sessions
### Session unique (sans jauges supplémentaires)
1. Timer démarré → `start-timer` prend les snapshots
2. Toutes les cibles atteintes → `complete-timer` déclenché
3. Session terminée : timer figé, bannière "Session terminée" visible, jauges continuent en fond
4. Clic "🔄 Nouvelle fournée" → `nouvelle-fournee` : reset complet + 1 DD neuve
### Session enchaînée (nouvelles stats à monter)
1. Session terminée (`alerted['__done__'] = true`)
2. Les **boutons de jauges sont déverrouillés** → l'utilisateur sélectionne de nouvelles jauges
3. L'utilisateur configure les niveaux de jauges pour la nouvelle session
4. Clic "▶ Démarrer" → `start-timer` détecte `alerted['__done__']`**démarrage initial** (pas reprise de pause) → nouveaux snapshots pris avec les stats actuelles des DD
5. Nouvelle session démarre : les stats des DD reflètent les gains de la session précédente
**Règle de déverrouillage des jauges** : `locked = started && !enc.alerted['__done__']`
Les jauges sont verrouillées uniquement pendant une session active (running ou en pause manuelle). Elles sont déverrouillées une fois la session terminée automatiquement.
**Bouton timer** : affiche "▶ Reprendre" uniquement en cas de pause manuelle (`pausedAt` et `!alerted['__done__']`). Après complétion automatique, affiche "▶ Démarrer" (nouvelle session).
---
## 19 bis. Commande `nouvelle-fournee`
Remet l'enclos dans un état "vierge" pour une nouvelle fournée complète :
- Reset timer (efface startTime, snapshots, alerted, recharges)
- Remet tous les niveaux de jauges à 0
- Supprime toutes les DDs
- Ajoute 1 nouvelle DD avec les stats de base (`serenite: 0, endurance: 0, maturite: 0, amour: 0, xp: 1`)
Distinct de `reset-timer` (qui remet seulement le timer à zéro, conserve les DDs et les jauges) et de `clear-enclos` (qui remet tout à zéro incluant les jauges actives et le nom).
---
## 18. Cycle de vie de `complete-timer`
La commande `complete-timer` représente la **fin naturelle d'une session** (toutes les cibles atteintes). Elle est distincte d'un simple `stop-timer` (pause manuelle).
### Ce que fait `complete-timer`
1. **Guard** : si `enc.timer.running = false` ou si `enc.alerted['__done__']` est déjà posé → retourne immédiatement (idempotent, pas de double alarme)
2. Pose `enc.timer.running = false`
3. Pose `enc.timer.pausedAt = Date.now()` — gèle `elapsed()` à l'instant de complétion
4. Pose `enc.alerted['__done__'] = true` — active le mode "continuation en fond" dans `elapsedLive`
5. Persiste l'état via `repo.save()`
6. Émet l'événement `timer-completed` → déclenche alarme audio + notification Windows/mobile
### Différence avec `stop-timer` (pause manuelle)
- `stop-timer` : pose `running = false` et `pausedAt` mais **ne pose pas** `alerted['__done__']`
- Conséquence : `elapsedLive` retourne `elapsed()` figé → toutes les jauges se figent sur la pause
- `complete-timer` : `alerted['__done__'] = true``elapsedLive` continue en temps réel → jauges continuent de se vider en fond
### Après `complete-timer`
| Élément | Comportement |
|---------|-------------|
| Affichage "Temps écoulé" (enclos + dashboard) | **Figé** — utilise `elapsed(timer)` = `(pausedAt - startTime - pausedMs) / 1000` |
| Jauges (niveau, barre) | **Continuent**`enclosGaugeCurGl` utilise `elapsedLive` |
| Stats estimées des DD | **Continuent**`computeGaugeLive` utilise `elapsedLive` |
| Countdown "Alarme dans" | Affiche `✅` |
| Bannière "Session terminée" | Visible dans la vue enclos |
---
## 19. Animations et détection de tick — `DragodindeCard`
### Détection de tick
Un "tick" survient toutes les 10 secondes. La carte DD détecte un nouveau tick en comparant `Math.floor(elapsedLive(enc) / 10)` avec la valeur précédente.
Utiliser `elapsedLive(enc)` (et non `elapsed`) garantit que les animations de tick **continuent** après la fin de session, le temps que toutes les jauges se vident en arrière-plan.
### `deltaActive` — quand afficher l'animation de delta
L'animation de delta ("+20 xp", "-10 sérenité"…) s'affiche à chaque tick **uniquement si la jauge est encore active**. Une jauge est considérée inactive quand la stat atteint son **cap absolu** (pas sa cible) :
```
Pour les jauges de stats : atAbsCap = (estStat >= statMax) ou (estStat <= statMin)
Pour la mangeoire (XP) : atAbsCap = (estLevel >= 200)
```
Cela signifie que l'animation continue après la cible de l'utilisateur (ex: sérénité cible -60 → animation continue jusqu'à -5000).
### Badge "✓ TERMINÉ"
Affiché sur la carte DD quand **toutes les jauges actives** ont `done = true` pour cette DD. Les jauges de stats et la mangeoire comptent toutes de la même façon — pas de distinction.
---
## 20. Événements domaine actifs
| Événement | Déclencheur | Effet |
|-----------|-------------|-------|
| `timer-completed` | `complete-timer` (toutes cibles atteintes) | Alarme audio + notification Windows + notification mobile ntfy |
| `gauge-threshold-reached` | Franchissement d'un palier de jauge | (usage interne) |
| `accouplement-registered` | Enregistrement d'un accouplement | Mise à jour des stats globales |
| `enclos-deleted` | Suppression d'un enclos | Nettoyage de l'état |
**Événements supprimés** (ancienne implémentation, ne plus utiliser) :
- `target-reached` — ancienne alarme intermédiaire quand une cible de stat était atteinte avant les autres
- `xp-target-reached` — ancienne alarme séparée pour la cible XP
Ces événements ont été remplacés par la règle d'alarme unique : **une seule alarme au timer le plus long**, déclenchée par `timer-completed` uniquement.