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

22 KiB
Raw Blame History

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__'] = trueelapsedLive 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) ContinuentenclosGaugeCurGl utilise elapsedLive
Stats estimées des DD ContinuentcomputeGaugeLive 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.