"""Spielmechanik: XP, Ränge, Abzeichen. Absichtlich großzügig ausgelegt. Ein Achtjähriger, der drei Tage lang nur Kohlmeisen bekommt, soll trotzdem vorankommen — die Seltenheitsstufen sorgen für die Ausschläge, die Grundpunkte für den stetigen Fortschritt. """ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta from django.db.models import Count, Sum from django.db.models.functions import TruncDate from django.utils import timezone from django.utils.translation import gettext as _ from django.utils.translation import gettext_noop from .models import Badge, DexEntry, EarnedBadge, Sighting, Species # Ein Erstfang ist der eigentliche Moment — dafür der dicke Multiplikator. FIRST_CATCH_MULTIPLIER = 3 RANKS = [ gettext_noop("Nestling"), gettext_noop("Ästling"), gettext_noop("Spatzenkenner"), gettext_noop("Meisenmeister"), gettext_noop("Federfuchser"), gettext_noop("Amselflüsterer"), gettext_noop("Spechtspäher"), gettext_noop("Falkenauge"), gettext_noop("Vogelmeister"), gettext_noop("Legende des Vogelhauses"), ] def xp_for_level(level: int) -> int: """Kumulierte XP, die für ``level`` nötig sind. Level 1 startet bei 0.""" if level <= 1: return 0 return int(100 * (level - 1) ** 1.5) def level_for_xp(xp: int) -> int: level = 1 while xp >= xp_for_level(level + 1): level += 1 if level > 200: # Sicherheitsnetz, nicht erreichbar break return level def rank_name(level: int) -> str: # Alle drei Level ein neuer Rang, oben gedeckelt. return _(RANKS[min((level - 1) // 3, len(RANKS) - 1)]) @dataclass class Progress: xp: int level: int rank: str xp_this_level: int xp_next_level: int species_seen: int species_total: int sightings: int badges: int @property def level_progress(self) -> float: span = self.xp_next_level - self.xp_this_level if span <= 0: return 1.0 return min(1.0, (self.xp - self.xp_this_level) / span) @property def xp_to_next(self) -> int: return max(0, self.xp_next_level - self.xp) def total_xp() -> int: from_sightings = Sighting.objects.aggregate(s=Sum("xp_awarded"))["s"] or 0 from_badges = ( EarnedBadge.objects.aggregate(s=Sum("badge__xp"))["s"] or 0 ) return int(from_sightings + from_badges) def progress() -> Progress: xp = total_xp() level = level_for_xp(xp) return Progress( xp=xp, level=level, rank=rank_name(level), xp_this_level=xp_for_level(level), xp_next_level=xp_for_level(level + 1), species_seen=DexEntry.objects.count(), species_total=Species.objects.count(), sightings=Sighting.objects.bestimmt().count(), badges=EarnedBadge.objects.count(), ) # -------------------------------------------------------------------------- # Abzeichen # -------------------------------------------------------------------------- BADGE_DEFS = [ ("erster_gast", gettext_noop("Erster Gast"), gettext_noop("Die allererste Sichtung im Vogelhaus."), "🐣", 50, 10), ("arten_5", gettext_noop("Fünf Freunde"), gettext_noop("5 verschiedene Arten gesammelt."), "🖐️", 75, 20), ("arten_10", gettext_noop("Zweistellig"), gettext_noop("10 verschiedene Arten gesammelt."), "🔟", 150, 21), ("arten_25", gettext_noop("Sammler"), gettext_noop("25 verschiedene Arten gesammelt."), "📚", 400, 22), ("arten_40", gettext_noop("Vogelalbum-Meister"), gettext_noop("40 verschiedene Arten gesammelt."), "👑", 1000, 23), ("fruehaufsteher", gettext_noop("Frühaufsteher"), gettext_noop("Eine Sichtung vor 6 Uhr morgens."), "🌅", 100, 30), ("nachtschicht", gettext_noop("Nachtschicht"), gettext_noop("Eine Sichtung nach 21 Uhr."), "🌙", 100, 31), ("volles_haus", gettext_noop("Volles Haus"), gettext_noop("5 verschiedene Arten an einem Tag."), "🏠", 200, 40), ("stammgast", gettext_noop("Stammgast"), gettext_noop("7 Tage in Folge mindestens eine Sichtung."), "🔥", 250, 41), ("dauergast", gettext_noop("Dauergast"), gettext_noop("Ein Besuch, der länger als eine Minute dauerte."), "⏱️", 100, 42), ("besucher_100", gettext_noop("Hundert Besuche"), gettext_noop("100 Sichtungen aufgezeichnet."), "💯", 200, 50), ("besucher_1000", gettext_noop("Tausend Besuche"), gettext_noop("1000 Sichtungen aufgezeichnet."), "🏆", 750, 51), ("klopfgeist", gettext_noop("Klopfgeist"), gettext_noop("Ein Specht war da."), "🪵", 250, 60), ("greifvogel_alarm", gettext_noop("Greifvogel-Alarm"), gettext_noop("Ein Greifvogel oder eine Eule vor der Linse."), "🦅", 500, 61), ("seltener_gast", gettext_noop("Seltener Gast"), gettext_noop("Eine Art der Stufe 'Selten' oder höher."), "💎", 200, 62), ] def sync_badges() -> int: """Legt fehlende Abzeichen an. Idempotent — läuft bei jeder Migration mit.""" created = 0 for code, name, desc, emoji, xp, sort in BADGE_DEFS: _, was_created = Badge.objects.update_or_create( code=code, defaults={ "name": name, "description": desc, "emoji": emoji, "xp": xp, "sort": sort, }, ) created += was_created # Codes, die es nicht mehr gibt, verschwinden — sonst bleibt nach einer # Umbenennung die alte Fassung als Leiche in der Liste stehen. bekannt = {code for code, *_ in BADGE_DEFS} Badge.objects.exclude(code__in=bekannt).delete() return created def _award(code: str, sighting: Sighting | None = None) -> Badge | None: badge = Badge.objects.filter(code=code).first() if badge is None or EarnedBadge.objects.filter(badge=badge).exists(): return None EarnedBadge.objects.create(badge=badge, sighting=sighting) return badge def _has_streak(days: int) -> bool: today = timezone.localdate() seen = { timezone.localtime(dt).date() for dt in Sighting.objects.bestimmt().filter( started_at__gte=timezone.now() - timedelta(days=days + 1) ).values_list("started_at", flat=True) } return all((today - timedelta(days=i)) in seen for i in range(days)) # 🔑 Die Schwellen stehen hier EINMAL und werden an zwei Stellen benutzt: # beim Vergeben (evaluate_badges) und beim Anzeigen des Fortschritts # (fortschritt_je_abzeichen). Zwei getrennte Listen wuerden frueher oder # spaeter auseinanderlaufen, und dann zeigt der Balken etwas anderes an, # als das Abzeichen verlangt. ARTEN_SCHWELLEN = ((5, "arten_5"), (10, "arten_10"), (25, "arten_25"), (40, "arten_40")) SICHTUNGS_SCHWELLEN = ((100, "besucher_100"), (1000, "besucher_1000")) TAGESARTEN_SCHWELLE = 5 # volles_haus STRAEHNE_SCHWELLE = 7 # stammgast def bildguete(sichtung) -> float: """Wie brauchbar ist das Foto dieser Sichtung? Groesser ist besser. 🔑 **Nicht die Erkennungssicherheit.** Die sagt, wie sicher sich das Modell bei der ART ist -- nicht, ob das Bild etwas taugt. Am 30.09.2026 wurde dadurch eine verwischte Aufnahme zum "besten Foto" der Kohlmeise: Sicherheit 0,945, aber nur 5 von 31 Bildern mit Vogel und der Kopf nicht zu erkennen. Die schoene scharfe Aufnahme (0,936, 23 von 67) landete dahinter. Stattdessen zwei Groessen, die beide messen "der Vogel war wirklich und deutlich da": Anteil der Bilder mit Vogel er blieb, statt kurz durchzuhuschen mittlerer Vogel-Wert er war deutlich zu sehen ⚠️ **Das ist ein Hilfsmass, keine Schaerfemessung.** Richtig waere, die Schaerfe des gespeicherten Bildes zu messen (Laplace-Varianz im Detector). Das hier kommt ohne Bildverarbeitung aus und trifft in der Praxis dieselbe Wahl. """ gesamt = sichtung.frames_total or 0 anteil = (sichtung.frames_with_bird or 0) / gesamt if gesamt else 0.0 wert = sichtung.mean_bird_score or 0.0 guete = anteil * wert # Voellig ohne Messwerte (alte Sichtungen) bleibt nur die Sicherheit -- # besser als gar keine Reihenfolge. return guete if guete > 0 else (sichtung.confidence or 0.0) / 1000 def beste_sichtung(sichtungen): """Die Sichtung mit dem brauchbarsten Foto, oder None.""" sichtungen = list(sichtungen) return max(sichtungen, key=bildguete) if sichtungen else None def fortschritt_je_abzeichen() -> dict[str, tuple[int, int]]: """Wie weit ist man bei den ZAEHLBAREN Abzeichen? code -> (ist, ziel). ⚠️ Absichtlich nicht fuer alle: "Ein Specht war da" oder "Eine Sichtung vor 6 Uhr" haben keinen Fortschritt -- sie passieren oder nicht. Ein Balken auf 0 % waere dort keine Information, sondern nur entmutigend. Aus der Rückmeldung am 26.09.2026: *"Bei den Abzeichen waer ein Fortschrittsbalken cool."* """ stand: dict[str, tuple[int, int]] = {} arten = DexEntry.objects.count() for ziel, code in ARTEN_SCHWELLEN: stand[code] = (arten, ziel) sichtungen = Sighting.objects.bestimmt().count() for ziel, code in SICHTUNGS_SCHWELLEN: stand[code] = (sichtungen, ziel) # Der beste Tag bisher -- nicht der heutige. Sonst faellt der Balken # jede Nacht auf null zurueck, obwohl man schon vier Arten hatte. bester_tag = ( Sighting.objects.bestimmt().mit_art() .annotate(tag=TruncDate("started_at")) .values("tag") .annotate(n=Count("art_id", distinct=True)) .order_by("-n") .first() ) stand["volles_haus"] = (bester_tag["n"] if bester_tag else 0, TAGESARTEN_SCHWELLE) stand["stammgast"] = (_laengste_straehne(), STRAEHNE_SCHWELLE) return stand def _laengste_straehne() -> int: """Die laengste Folge von Tagen mit mindestens einer Sichtung.""" tage = sorted({ timezone.localtime(z).date() for z in Sighting.objects.bestimmt().values_list("started_at", flat=True) }) if not tage: return 0 beste = laufend = 1 for vorher, jetzt in zip(tage, tage[1:]): laufend = laufend + 1 if (jetzt - vorher).days == 1 else 1 beste = max(beste, laufend) return beste def evaluate_badges(sighting: Sighting) -> list[Badge]: """Prüft nach einer Sichtung alle Abzeichen. Gibt die neu verdienten zurück. ⚠️ **Eine unbestimmte Sichtung ist kein Besuch und verdient nichts.** Das musste hier eigens hin, weil die Prüfungen weiter unten verschiedener Herkunft sind: die auf Gesamtzahlen (``bestimmt()``, ``arten_anzahl()``) ändern sich durch eine unbestimmte Sichtung ohnehin nicht, und ab ``effective_species`` wird sauber geprüft — aber die Abzeichen für Uhrzeit und Dauer hingen allein am Zeitstempel und wurden deshalb auch für Bilder ohne Vogel vergeben. 🔑 Am 27.09.2026 ist genau das passiert: Die Vogeltränke meldete abends 24 Spiegelungen des Mondes auf dem Wasser, und die erste davon brachte „Nachtschicht" samt 100 XP ein. Jede weitere Spiegelung nach 21 Uhr hätte es erneut getan. """ if sighting.effective_species is None: return [] earned: list[Badge] = [] def add(code: str) -> None: badge = _award(code, sighting) if badge: earned.append(badge) if Sighting.objects.bestimmt().count() == 1: add("erster_gast") species_count = DexEntry.objects.count() for threshold, code in ARTEN_SCHWELLEN: if species_count >= threshold: add(code) local = timezone.localtime(sighting.started_at) if local.hour < 6: add("fruehaufsteher") if local.hour >= 21: add("nachtschicht") if sighting.duration_s > 60: add("dauergast") total = Sighting.objects.bestimmt().count() for schwelle, code in SICHTUNGS_SCHWELLEN: if total >= schwelle: add(code) day_species = ( Sighting.objects.filter(started_at__date=local.date()).arten_anzahl() ) if day_species >= TAGESARTEN_SCHWELLE: add("volles_haus") if _has_streak(STRAEHNE_SCHWELLE): add("stammgast") species = sighting.effective_species if species: if "specht" in species.slug: add("klopfgeist") if species.rarity in ("episch", "legendaer"): add("greifvogel_alarm") if species.rarity in ("selten", "episch", "legendaer"): add("seltener_gast") return earned # -------------------------------------------------------------------------- # Aufnahme einer neuen Sichtung # -------------------------------------------------------------------------- @dataclass class Reward: sighting: Sighting xp: int is_first: bool badges: list[Badge] level_before: int level_after: int @property def leveled_up(self) -> bool: return self.level_after > self.level_before def register(sighting: Sighting) -> Reward: """Verbucht eine frisch gespeicherte Sichtung: XP, Dex-Eintrag, Abzeichen. Erwartet, dass ``sighting`` bereits in der DB liegt (die Badge-Prüfungen zählen über die Tabelle). """ level_before = level_for_xp(total_xp()) species = sighting.effective_species xp = 0 is_first = False if species is not None: entry = DexEntry.objects.filter(species=species).first() is_first = entry is None xp = species.xp * (FIRST_CATCH_MULTIPLIER if is_first else 1) # Eine sichere Bestimmung ist mehr wert als eine wackelige. xp = int(round(xp * (0.5 + 0.5 * min(1.0, sighting.confidence)))) if entry is None: DexEntry.objects.create( species=species, first_seen=sighting.started_at, last_seen=sighting.started_at, count=1, best_sighting=sighting, ) else: entry.count += 1 entry.last_seen = max(entry.last_seen, sighting.started_at) # 🔑 Nicht nach Sicherheit, sondern nach Bildguete -- siehe dort. best = entry.best_sighting if best is None or bildguete(sighting) > bildguete(best): entry.best_sighting = sighting entry.save(update_fields=["count", "last_seen", "best_sighting"]) sighting.is_first_of_species = is_first sighting.xp_awarded = xp sighting.save(update_fields=["is_first_of_species", "xp_awarded"]) badges = evaluate_badges(sighting) return Reward( sighting=sighting, xp=xp, is_first=is_first, badges=badges, level_before=level_before, level_after=level_for_xp(total_xp()), )