"""放松一下 game catalog — code-defined specs + the engine contract. A game engine is two async-safe functions: * ``build(db, person) -> BuildResult`` — server-side round setup. The ``payload`` is the FULL secret state persisted on the session (incl. answers/solutions); ``public`` is the projection the player receives. * ``grade(payload, submission, elapsed_ms) -> GradeResult`` — pure server-side scoring against the persisted secret state. This is why arcade leaderboards can trust scores: the client only ever reports *choices*, never a score. Adding a game = one engine module + one ``GameSpec`` entry here (+ its i18n keys ``arcade.game..name`` / ``.tagline`` on the frontend). Ordering is the display order on the arcade wall. """ from __future__ import annotations from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.identity.person_models import IdentityPerson CATEGORY_SECURITY = "security" CATEGORY_RELAX = "relax" CATEGORY_KNOWLEDGE = "knowledge" @dataclass(frozen=True) class BuildResult: payload: dict[str, Any] # full secret state — persisted, never sent raw public: dict[str, Any] # what the player gets at round start # The resolved sub-mode this round was built for (e.g. trivia category # or 'mixed'); persisted on the session for per-category leaderboards. # None for games with no sub-modes. variant: str | None = None @dataclass(frozen=True) class GradeResult: score: int detail: dict[str, Any] # per-item breakdown for the result screen @dataclass(frozen=True) class GameSpec: key: str category: str # security | relax icon: str # emoji, shared display language with badges time_limit_ms: int # build(db, person, variant) — variant is the caller-chosen sub-mode # (trivia category / 'mixed' / None). Games with no sub-modes ignore it. build: Callable[[AsyncSession, IdentityPerson, str | None], Awaitable[BuildResult]] grade: Callable[[dict, dict, int], GradeResult] # Sub-modes selectable at start (e.g. trivia categories). Empty = none. variants: tuple[str, ...] = () # Whether this game appears on the COMPETITIVE best-score leaderboard. # False for games that MUST ship their full solution to the client to # render (舒尔特/记忆翻牌/色词) — a scripted client could read the answer # and post an instant near-max score, so a public best-score board for # them would be unfair. They still track a PERSONAL best on the home # wall; there's just no cross-player ranking. Answer-secret games (the # trivia/security games, whose truth stays server-side) keep this True. competitive: bool = True def _registry() -> list[GameSpec]: # Imported lazily so the models/engines don't load at module import in # migration / tooling contexts. from app.domains.arcade.engines import ( memory_match, password_sniper, phishing_spot, quiz_rush, schulte, spot_risk, stroop, ) from app.domains.arcade.trivia_models import TRIVIA_CATEGORIES return [ # ── Knowledge / trivia (general knowledge — its OWN bank, NOT the # security quiz; the daily quiz earns points, this spends them) ── GameSpec( key="quiz_rush", category=CATEGORY_KNOWLEDGE, icon="🧠", time_limit_ms=quiz_rush.TIME_LIMIT_MS, build=quiz_rush.build, grade=quiz_rush.grade, # 'mixed' + one per trivia category (per-category leaderboards). variants=("mixed", *TRIVIA_CATEGORIES), ), # ── Security games (the content IS the curriculum) ── GameSpec( key="password_sniper", category=CATEGORY_SECURITY, icon="🎯", time_limit_ms=password_sniper.TIME_LIMIT_MS, build=password_sniper.build, grade=password_sniper.grade, ), GameSpec( key="phishing_spot", category=CATEGORY_SECURITY, icon="🎣", time_limit_ms=phishing_spot.TIME_LIMIT_MS, build=phishing_spot.build, grade=phishing_spot.grade, ), GameSpec( key="spot_risk", category=CATEGORY_SECURITY, icon="🔍", time_limit_ms=spot_risk.TIME_LIMIT_MS, build=spot_risk.build, grade=spot_risk.grade, ), # ── Relax games (attention / memory / reflex breaks) ── GameSpec( key="schulte", category=CATEGORY_RELAX, icon="🔢", time_limit_ms=schulte.TIME_LIMIT_MS, build=schulte.build, grade=schulte.grade, variants=schulte.VARIANT_KEYS, # 经典/6×6/7×7/旋转/环形 competitive=False, # layout is public → personal-best only ), GameSpec( key="memory_match", category=CATEGORY_RELAX, icon="🃏", time_limit_ms=memory_match.TIME_LIMIT_MS, build=memory_match.build, grade=memory_match.grade, variants=memory_match.VARIANT_KEYS, # 轻松/经典/进阶/烧脑 competitive=False, # layout is public → personal-best only ), GameSpec( key="stroop", category=CATEGORY_RELAX, icon="🌈", time_limit_ms=stroop.TIME_LIMIT_MS, build=stroop.build, grade=stroop.grade, competitive=False, # word+ink are public → personal-best only ), ] GAMES: list[GameSpec] = _registry() GAMES_BY_KEY: dict[str, GameSpec] = {g.key: g for g in GAMES} # Submitting after the limit gets this much slack for network latency; # beyond it the round is graded as expired (score 0). SUBMIT_GRACE_MS = 10_000 __all__ = [ "BuildResult", "GradeResult", "GameSpec", "GAMES", "GAMES_BY_KEY", "SUBMIT_GRACE_MS", "CATEGORY_SECURITY", "CATEGORY_RELAX", "CATEGORY_KNOWLEDGE", ]