new file: .gitignore new file: Dockerfile new file: README.md new file: app.py new file: app/__init__.py new file: app/game.py new file: app/models.py new file: app/routes.py new file: app/seed.py new file: data/movies.json new file: docker-compose.yml new file: requirements.txt new file: static/css/style.css new file: static/js/game.js new file: static/js/player.js new file: templates/index.html
203 lines
6.9 KiB
Python
203 lines
6.9 KiB
Python
import hashlib
|
|
import random
|
|
from datetime import date, datetime, time, timedelta
|
|
|
|
from . import db
|
|
from .models import Movie, Player, Session, Universe
|
|
|
|
# Statusklassen fuer die Zellen
|
|
GREEN = "green"
|
|
YELLOW = "yellow"
|
|
RED = "red"
|
|
|
|
|
|
def get_max_attempts():
|
|
from flask import current_app
|
|
return current_app.config.get("MAX_ATTEMPTS", 10)
|
|
|
|
|
|
def _game_date(today=None):
|
|
"""Ermittelt den 'Spieltag' basierend auf der globalen Reset-Uhrzeit (UTC)."""
|
|
from flask import current_app
|
|
reset_hour = current_app.config.get("DAILY_RESET_UTC", 9)
|
|
now = today or datetime.utcnow()
|
|
game_date = now.date()
|
|
# Vor der Resetzeit gilt noch der Vortag
|
|
if now.time() < time(reset_hour % 24, 0, 0):
|
|
game_date = game_date - timedelta(days=1)
|
|
return game_date
|
|
|
|
|
|
def daily_target(game_date=None):
|
|
"""Bestimmt deterministisch den taeglichen Ziel-Film (fuer alle gleich)."""
|
|
game_date = game_date or _game_date()
|
|
movies = Movie.query.order_by(Movie.id).all()
|
|
if not movies:
|
|
return None
|
|
idx = int(hashlib.sha256(str(game_date).encode()).hexdigest(), 16) % len(movies)
|
|
return movies[idx]
|
|
|
|
|
|
def random_target():
|
|
movies = Movie.query.order_by(Movie.id).all()
|
|
if not movies:
|
|
return None
|
|
return random.choice(movies)
|
|
|
|
|
|
def get_or_create_player(nickname):
|
|
player = Player.query.filter_by(nickname=nickname).first()
|
|
if player is None:
|
|
player = Player(nickname=nickname)
|
|
db.session.add(player)
|
|
db.session.commit()
|
|
return player
|
|
|
|
|
|
def _series(normalized_title):
|
|
"""Erstellt eine Normalisierung fuer Titel-Vergleiche (nur Buchstaben, klein)."""
|
|
return "".join(ch for ch in normalized_title.lower() if ch.isalnum())
|
|
|
|
|
|
def match_platforms(guess_movie, target_movie):
|
|
guess = set(p.name for p in guess_movie.platforms)
|
|
target = set(p.name for p in target_movie.platforms)
|
|
if guess == target:
|
|
return GREEN
|
|
# gelb: mindestens eine gemeinsame Platform
|
|
if guess & target:
|
|
return YELLOW
|
|
return RED
|
|
|
|
|
|
def match_genres(guess_movie, target_movie):
|
|
guess = set(g.name for g in guess_movie.genres)
|
|
target = set(g.name for g in target_movie.genres)
|
|
if guess == target:
|
|
return GREEN
|
|
if guess & target:
|
|
return YELLOW
|
|
return RED
|
|
|
|
|
|
def match_year(guess_year, target_year):
|
|
if guess_year == target_year:
|
|
return GREEN, "equal"
|
|
if guess_year < target_year:
|
|
return YELLOW, "up" # Ziel liegt hoeher (spaeter)
|
|
return YELLOW, "down" # Ziel liegt tiefer (frueher)
|
|
|
|
|
|
def match_fsk(guess_fsk, target_fsk):
|
|
if guess_fsk == target_fsk:
|
|
return GREEN
|
|
if guess_fsk is None or target_fsk is None:
|
|
return YELLOW if guess_fsk == target_fsk else RED
|
|
return RED
|
|
|
|
|
|
def match_director(guess_director, target_director):
|
|
if not guess_director or not target_director:
|
|
return GREEN if guess_director == target_director else RED
|
|
if _series(guess_director) == _series(target_director):
|
|
return GREEN
|
|
return RED
|
|
|
|
|
|
def match_studio(guess_studio, target_studio):
|
|
if (guess_studio or "").lower() == (target_studio or "").lower():
|
|
return GREEN
|
|
return RED
|
|
|
|
|
|
def match_budget(guess_budget, target_budget):
|
|
if guess_budget is None or target_budget is None:
|
|
return RED if guess_budget != target_budget else GREEN
|
|
if guess_budget == target_budget:
|
|
return GREEN, "equal"
|
|
if guess_budget < target_budget:
|
|
return YELLOW, "up"
|
|
return YELLOW, "down"
|
|
|
|
|
|
def match_universe(guess_universe, target_universe):
|
|
# Fuer beide None => green (kein Universum)
|
|
if (guess_universe or "") == (target_universe or ""):
|
|
return GREEN
|
|
if not guess_universe or not target_universe:
|
|
return RED
|
|
return RED
|
|
|
|
|
|
def compare(guess_movie, target_movie):
|
|
"""Vergleicht einen Tipp mit dem Ziel-Film und liefert die 9 Zellen."""
|
|
ys, ydir = match_year(guess_movie.year, target_movie.year)
|
|
bs, bdir = match_budget(guess_movie.budget, target_movie.budget)
|
|
|
|
return {
|
|
"title": guess_movie.title,
|
|
"platforms": {"status": match_platforms(guess_movie, target_movie), "value": sorted(p.name for p in guess_movie.platforms)},
|
|
"genres": {"status": match_genres(guess_movie, target_movie), "value": sorted(g.name for g in guess_movie.genres)},
|
|
"year": {"status": ys, "value": guess_movie.year, "direction": ydir, "diff": target_movie.year - guess_movie.year},
|
|
"fsk": {"status": match_fsk(guess_movie.fsk, target_movie.fsk), "value": guess_movie.fsk},
|
|
"studio": {"status": match_studio(guess_movie.studio.name if guess_movie.studio else None, target_movie.studio.name if target_movie.studio else None), "value": guess_movie.studio.name if guess_movie.studio else None},
|
|
"director": {"status": match_director(guess_movie.director, target_movie.director), "value": guess_movie.director or None},
|
|
"budget": {"status": bs, "value": guess_movie.budget, "direction": bdir, "diff": (target_movie.budget - guess_movie.budget) if guess_movie.budget is not None and target_movie.budget is not None else None},
|
|
"universe": {"status": match_universe(guess_movie.universe.name if guess_movie.universe else None, target_movie.universe.name if target_movie.universe else None), "value": guess_movie.universe.name if guess_movie.universe else None},
|
|
}
|
|
|
|
|
|
def is_correct(compared):
|
|
"""Prueft ob alle Zellen gruen sind (== Ziel gefunden)."""
|
|
return all(cell["status"] == GREEN for cell in compared.values() if cell is not compared["title"])
|
|
|
|
|
|
def finish_session(session, won):
|
|
"""Markiert eine Runde als gewonnen/verloren und aktualisiert den Str eak."""
|
|
session.status = "won" if won else "lost"
|
|
session.won = won
|
|
db.session.commit()
|
|
|
|
|
|
def _streak_for(player, mode, won):
|
|
"""Berechnet den aktuellen Str eak (Sieg bzw. Niederlage) fuer Spieler+Modus."""
|
|
sessions = (
|
|
Session.query
|
|
.filter_by(player_id=player.id, mode=mode)
|
|
.order_by(Session.id.desc())
|
|
.all()
|
|
)
|
|
count = 0
|
|
for s in sessions:
|
|
if s.won is None:
|
|
continue
|
|
if s.won == won:
|
|
count += 1
|
|
else:
|
|
break
|
|
return count
|
|
|
|
|
|
def get_streaks(player_id):
|
|
"""Liefert getrennte Sieg-/Niederlagen-Str eaks fuer Tages- und Unbegrenzt-Modus."""
|
|
player = Player.query.get(player_id)
|
|
if player is None:
|
|
return None
|
|
return {
|
|
"daily": {
|
|
"win": _streak_for(player, "daily", True),
|
|
"loss": _streak_for(player, "daily", False),
|
|
},
|
|
"unlimited": {
|
|
"win": _streak_for(player, "unlimited", True),
|
|
"loss": _streak_for(player, "unlimited", False),
|
|
},
|
|
}
|
|
|
|
|
|
def existing_daily_session(player_id, game_date):
|
|
"""Gibt eine vorhandene, abgeschlossene Tages-Runde zurueck (falls vorhanden)."""
|
|
return Session.query.filter_by(
|
|
player_id=player_id, mode="daily", date=game_date
|
|
).order_by(Session.id.desc()).first()
|