new file: .env.example

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
This commit is contained in:
SimolZimol
2026-08-07 20:48:08 +02:00
commit 526b0353e7
17 changed files with 2722 additions and 0 deletions

39
app/__init__.py Normal file
View File

@@ -0,0 +1,39 @@
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
def create_app():
app = Flask(__name__)
# Konfiguration aus Umgebungsvariablen (Coolify)
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-key")
app.config["MAX_ATTEMPTS"] = int(os.environ.get("MAX_ATTEMPTS", "10"))
app.config["DAILY_RESET_UTC"] = int(os.environ.get("DAILY_RESET_UTC", "9"))
db_host = os.environ.get("DB_HOST", "localhost")
db_port = os.environ.get("DB_PORT", "5432")
db_user = os.environ.get("DB_USER", "filmdle")
db_password = os.environ.get("DB_PASSWORD", "")
db_name = os.environ.get("DB_DATABASE", "filmdle")
app.config["SQLALCHEMY_DATABASE_URI"] = (
f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(app)
from . import routes # noqa: F401
with app.app_context():
db.create_all()
# Seed (idempotent) beim Start, damit die App sofort funktioniert
from .seed import seed_all
seed_all()
return app

202
app/game.py Normal file
View File

@@ -0,0 +1,202 @@
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()

85
app/models.py Normal file
View File

@@ -0,0 +1,85 @@
from datetime import datetime
from . import db
# ---- Referenz-/Nachschlagetabellen (feste Liste) ----
movie_genres = db.Table(
"movie_genres",
db.Column("movie_id", db.Integer, db.ForeignKey("movies.id"), primary_key=True),
db.Column("genre_id", db.Integer, db.ForeignKey("genres.id"), primary_key=True),
)
movie_platforms = db.Table(
"movie_platforms",
db.Column("movie_id", db.Integer, db.ForeignKey("movies.id"), primary_key=True),
db.Column("platform_id", db.Integer, db.ForeignKey("platforms.id"), primary_key=True),
)
class Genre(db.Model):
__tablename__ = "genres"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(60), unique=True, nullable=False)
class Platform(db.Model):
__tablename__ = "platforms"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(60), unique=True, nullable=False)
class Studio(db.Model):
__tablename__ = "studios"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(60), unique=True, nullable=False)
class Universe(db.Model):
__tablename__ = "universes"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(60), unique=True, nullable=False)
# ---- Haupttabelle ----
class Movie(db.Model):
__tablename__ = "movies"
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False, index=True)
year = db.Column(db.Integer, nullable=False)
fsk = db.Column(db.Integer, nullable=True) # 0, 6, 12, 16, 18
studio_id = db.Column(db.Integer, db.ForeignKey("studios.id"), nullable=True)
director = db.Column(db.String(120), nullable=True)
budget = db.Column(db.Integer, nullable=True) # in USD
universe_id = db.Column(db.Integer, db.ForeignKey("universes.id"), nullable=True)
studio = db.relationship("Studio")
universe = db.relationship("Universe")
genres = db.relationship("Genre", secondary=movie_genres, lazy="joined")
platforms = db.relationship("Platform", secondary=movie_platforms, lazy="joined")
# ---- Spieler & Runden ----
class Player(db.Model):
__tablename__ = "players"
id = db.Column(db.Integer, primary_key=True)
nickname = db.Column(db.String(60), unique=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class Session(db.Model):
__tablename__ = "sessions"
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey("players.id"), nullable=False)
mode = db.Column(db.String(20), nullable=False) # 'daily' | 'unlimited'
target_movie_id = db.Column(db.Integer, db.ForeignKey("movies.id"), nullable=False)
status = db.Column(db.String(20), nullable=False, default="in_progress") # won|lost|in_progress
attempts_used = db.Column(db.Integer, nullable=False, default=0)
won = db.Column(db.Boolean, nullable=True) # True won / False lost / None in-progress
date = db.Column(db.Date, nullable=True) # nur fuer 'daily'
created_at = db.Column(db.DateTime, default=datetime.utcnow)
player = db.relationship("Player")
target = db.relationship("Movie")

226
app/routes.py Normal file
View File

@@ -0,0 +1,226 @@
from flask import Blueprint, jsonify, render_template, request
from . import db
from .game import (
_game_date, compare, daily_target, existing_daily_session, finish_session,
get_max_attempts, get_or_create_player, get_streaks, is_correct, random_target,
)
from .models import Movie, Player, Session
bp = Blueprint("main", __name__)
# ---------- Seiten ----------
@bp.route("/")
def index():
return render_template("index.html", mode="daily")
@bp.route("/unlimited")
def unlimited():
return render_template("index.html", mode="unlimited")
# ---------- API ----------
@bp.route("/api/player", methods=["POST"])
def api_player():
data = request.get_json(silent=True) or {}
nickname = (data.get("nickname") or "").strip()
if not nickname:
return jsonify({"error": "nickname required"}), 400
player = get_or_create_player(nickname)
return jsonify({
"player": {"id": player.id, "nickname": player.nickname},
"streaks": get_streaks(player.id),
})
@bp.route("/api/search")
def api_search():
q = (request.args.get("q") or "").strip().lower()
if not q:
return jsonify([])
movies = Movie.query.filter(Movie.title.ilike(f"%{q}%")).limit(10).all()
return jsonify([
{"id": m.id, "title": m.title, "year": m.year}
for m in movies
])
@bp.route("/api/start", methods=["POST"])
def api_start():
data = request.get_json(silent=True) or {}
player_id = data.get("player_id")
mode = data.get("mode", "daily")
if not player_id:
return jsonify({"error": "player_id required"}), 400
if Player.query.get(player_id) is None:
return jsonify({"error": "player not found"}), 404
if mode not in ("daily", "unlimited"):
return jsonify({"error": "invalid mode"}), 400
max_attempts = get_max_attempts()
if mode == "daily":
game_date = _game_date()
target = daily_target(game_date)
# Bereits heute gespielt?
existing = existing_daily_session(player_id, game_date)
if existing and existing.status != "in_progress":
return jsonify({
"session_id": existing.id,
"mode": mode,
"max_attempts": max_attempts,
"status": existing.status,
"already_played": True,
"target": _movie_public(target),
"attempts_used": existing.attempts_used,
})
# In-progress Rueckgabe, sonst neue Runde
if existing:
return jsonify({
"session_id": existing.id,
"mode": mode,
"max_attempts": max_attempts,
"status": existing.status,
"already_played": False,
"attempts_used": existing.attempts_used,
})
session = Session(
player_id=player_id, mode=mode, target_movie_id=target.id,
status="in_progress", attempts_used=0, date=game_date,
)
db.session.add(session)
db.session.commit()
return jsonify({
"session_id": session.id,
"mode": mode,
"max_attempts": max_attempts,
"status": "in_progress",
"already_played": False,
"attempts_used": 0,
})
# Unbegrenzt: neue Zufallsrunde
target = random_target()
if target is None:
return jsonify({"error": "no movies"}), 500
session = Session(
player_id=player_id, mode="unlimited", target_movie_id=target.id,
status="in_progress", attempts_used=0,
)
db.session.add(session)
db.session.commit()
return jsonify({
"session_id": session.id,
"mode": mode,
"max_attempts": max_attempts,
"status": "in_progress",
"already_played": False,
"attempts_used": 0,
})
@bp.route("/api/guess", methods=["POST"])
def api_guess():
data = request.get_json(silent=True) or {}
session_id = data.get("session_id")
movie_id = data.get("movie_id")
if not session_id or not movie_id:
return jsonify({"error": "session_id and movie_id required"}), 400
session = Session.query.get(session_id)
if session is None:
return jsonify({"error": "session not found"}), 404
if session.status != "in_progress":
return jsonify({"error": "session already finished", "status": session.status}), 409
guess = Movie.query.get(movie_id)
if guess is None:
return jsonify({"error": "movie not found"}), 404
target = Movie.query.get(session.target_movie_id)
max_attempts = get_max_attempts()
compared = compare(guess, target)
session.attempts_used += 1
used = session.attempts_used
finished = False
status = "in_progress"
won = False
if is_correct(compared):
finish_session(session, True)
finished = True
won = True
status = "won"
elif used >= max_attempts:
finish_session(session, False)
finished = True
won = False
status = "lost"
response = {
"session_id": session.id,
"attempts_used": used,
"max_attempts": max_attempts,
"status": status,
"finished": finished,
"won": won,
"row": compared,
}
if finished:
response["target"] = _movie_public(target)
response["streaks"] = get_streaks(session.player_id)
return jsonify(response)
@bp.route("/api/streaks", methods=["POST"])
def api_streaks():
data = request.get_json(silent=True) or {}
player_id = data.get("player_id")
if not player_id:
return jsonify({"error": "player_id required"}), 400
return jsonify(get_streaks(player_id) or {"error": "player not found"})
@bp.route("/api/new-game", methods=["POST"])
def api_new_game():
"""Startet (fuer den Unbegrenzt-Modus) eine neue Zufallsrunde."""
data = request.get_json(silent=True) or {}
player_id = data.get("player_id")
if not player_id or Player.query.get(player_id) is None:
return jsonify({"error": "player not found"}), 404
target = random_target()
if target is None:
return jsonify({"error": "no movies"}), 500
session = Session(
player_id=player_id, mode="unlimited", target_movie_id=target.id,
status="in_progress", attempts_used=0,
)
db.session.add(session)
db.session.commit()
return jsonify({
"session_id": session.id,
"mode": "unlimited",
"max_attempts": get_max_attempts(),
"status": "in_progress",
"attempts_used": 0,
})
def _movie_public(movie):
return {
"id": movie.id,
"title": movie.title,
"year": movie.year,
"fsk": movie.fsk,
"studio": movie.studio.name if movie.studio else None,
"director": movie.director,
"budget": movie.budget,
"universe": movie.universe.name if movie.universe else None,
"genres": [g.name for g in movie.genres],
"platforms": [p.name for p in movie.platforms],
}

89
app/seed.py Normal file
View File

@@ -0,0 +1,89 @@
import json
import os
from . import db
from .models import Genre, Movie, Platform, Studio, Universe
# Feste Referenzlisten (die moeglichen Werte fuer Plattform/Genre/Studio/Universum)
PLATFORMS = [
"Netflix", "Disney+", "Prime Video", "Max", "Apple TV+", "Paramount+",
"Blu-ray", "DVD", "4K UHD", "Cinema"
]
GENRES = [
"Action", "Abenteuer", "Animation", "Komödie", "Krimi", "Drama", "Fantasy",
"Horror", "Mystery", "Romantik", "Sci-Fi", "Thriller", "Western", "Musical", "Krieg", "Biografie"
]
STUDIOS = [
"Warner Bros", "Disney", "Universal", "Paramount", "Sony Pictures",
"20th Century", "MGM", "Lionsgate", "A24", "DreamWorks", "New Line",
"Orion", "Neon", "CJ Entertainment", "Show East", "Dimension", "Netflix"
]
UNIVERSES = [
"MCU", "Star Wars", "DC", "Wizarding World", "Fast & Furious", "Herr der Ringe", "Spider-Man"
]
def _get_or_create(model, name):
if not name:
return None
obj = model.query.filter_by(name=name).first()
if obj is None:
obj = model(name=name)
db.session.add(obj)
db.session.flush()
return obj
def seed_all():
"""Idempotent: legt Referenzlisten und Filme an, ohne bestehende zu duplizieren."""
# Referenzlisten
for name in PLATFORMS:
_get_or_create(Platform, name)
for name in GENRES:
_get_or_create(Genre, name)
for name in STUDIOS:
_get_or_create(Studio, name)
for name in UNIVERSES:
_get_or_create(Universe, name)
db.session.commit()
# Filme aus JSON laden
_seed_movies_from_json()
def _seed_movies_from_json():
data_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "movies.json"
)
if not os.path.exists(data_path):
return
with open(data_path, "r", encoding="utf-8") as f:
movies = json.load(f)
# Nur hinzufuegen, wenn noch kein Film in der DB ist (idempotent ueber den ganzen Datensatz)
if Movie.query.count() > 0:
return
for entry in movies:
movie = Movie(
title=entry["title"],
year=entry["year"],
fsk=entry.get("fsk"),
director=entry.get("director"),
budget=entry.get("budget"),
studio=_get_or_create(Studio, entry.get("studio")),
universe=_get_or_create(Universe, entry.get("universe")),
)
db.session.add(movie)
db.session.flush()
for gname in entry.get("genres", []):
movie.genres.append(_get_or_create(Genre, gname))
for pname in entry.get("platforms", []):
movie.platforms.append(_get_or_create(Platform, pname))
db.session.commit()