Das Abzeichen hing an der Seltenheit statt an der Vogelgruppe: "Ein Greifvogel oder eine Eule vor der Linse" wurde fuer jede epische oder legendaere Art vergeben. Von 148 Arten sind 21 Greifvoegel oder Eulen, 96 sind episch oder legendaer. Aufgefallen an einem Weissstorch. Die Warteschlange fragte alle 60 Sekunden nach Arbeit. Das war nie eine Hintergrundaufgabe, sondern die Zeit, die ein Mensch vor "Der Detektor schaut sich das an …" wartet, nachdem er ein Foto geschickt hat. Jetzt 5 Sekunden; die Abfrage ist ein kurzes JSON gegen die eigene Instanz. Drei Pruefungen fuer das Abzeichen, eine davon mit dem Storch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
437 lines
15 KiB
Python
437 lines
15 KiB
Python
"""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 species.typ == "specht" or "specht" in species.slug:
|
|
add("klopfgeist")
|
|
# 🔴 **Hier stand die Seltenheit statt der Vogelgruppe.** Das Abzeichen
|
|
# heißt "Greifvogel-Alarm" und verspricht "ein Greifvogel oder eine
|
|
# Eule vor der Linse" — vergeben wurde es für jede epische oder
|
|
# legendäre Art. Von 148 Arten im Katalog sind 21 Greifvögel oder
|
|
# Eulen, aber 75 weitere epische; die bekamen es zu Unrecht.
|
|
#
|
|
# Aufgefallen am 24.10.2026, als Christian in der App einen
|
|
# Weißstorch bestimmte und fragte, ob der ein Greifvogel sei. Der
|
|
# Befund stand seit dem 01.10.2026 in meinen Notizen und war als
|
|
# "Nebenbefund" liegengeblieben.
|
|
#
|
|
# 🔑 Eine Regel, die etwas anderes tut als ihre Beschreibung, ist
|
|
# nicht ungenau — sie sagt dem Nutzer etwas Falsches.
|
|
if species.typ in ("greifvogel", "eule"):
|
|
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()),
|
|
)
|