"""知识竞速 (Knowledge Rush) — 60-second rapid-fire general-knowledge round. Draws from the ARCADE TRIVIA bank (常识/历史/科学/地理/中华文化/生活), NOT the security-quiz bank: the daily security quiz PRODUCES points and this game SPENDS them, so the two banks are deliberately distinct. A round is played in a category ``variant`` (or ``mixed``), which is persisted on the session so per-category leaderboards ("who knows history best?") work. Correct keys + explanations stay in the secret payload; the player gets prompt/options only and submits picks in one shot. Scoring: BASE per correct answer + a combo bonus for consecutive hits, in issued order. """ from __future__ import annotations import logging from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import AppException from app.domains.arcade.catalog import BuildResult, GradeResult from app.domains.arcade.trivia_models import ( QUESTION_ACTIVE, TRIVIA_CATEGORIES, ArcadeTriviaQuestion, ) from app.identity.person_models import IdentityPerson logger = logging.getLogger(__name__) TIME_LIMIT_MS = 60_000 QUESTION_COUNT = 12 MIN_BANK = 4 # below this the round can't start (pre-charge check) BASE_POINTS = 100 COMBO_STEP = 20 # extra per consecutive hit beyond the first COMBO_CAP = 5 # combo bonus stops growing after this many MIXED = "mixed" def _normalize_keys(value: str | None) -> str: return "".join(sorted({c for c in (value or "").upper() if c.isalpha()})) def _resolve_variant(variant: str | None) -> str: """A round is 'mixed' unless a real trivia category was requested.""" if variant and variant in TRIVIA_CATEGORIES: return variant return MIXED async def build( db: AsyncSession, person: IdentityPerson, variant: str | None = None, ) -> BuildResult: resolved = _resolve_variant(variant) stmt = ( select(ArcadeTriviaQuestion) .where(ArcadeTriviaQuestion.status == QUESTION_ACTIVE) .order_by(func.rand()) .limit(QUESTION_COUNT) ) if resolved != MIXED: stmt = stmt.where(ArcadeTriviaQuestion.category == resolved) picked = list((await db.execute(stmt)).scalars().all()) if len(picked) < MIN_BANK: raise AppException( code="ARCADE_GAME_UNAVAILABLE", message="Not enough questions in this category to start a round.", status_code=422, ) items_secret: list[dict[str, Any]] = [] items_public: list[dict[str, Any]] = [] for q in picked: items_secret.append({ "question_id": str(q.id), "correct_key": _normalize_keys(q.correct_key), "explanation": q.explanation, "prompt": q.prompt, "options": q.options, "category": q.category, }) items_public.append({ "question_id": str(q.id), "prompt": q.prompt, "options": q.options, "category": q.category, }) return BuildResult( payload={"items": items_secret, "variant": resolved}, public={ "questions": items_public, "question_count": len(items_public), "variant": resolved, }, variant=resolved, ) def grade(payload: dict, submission: dict, elapsed_ms: int) -> GradeResult: """Score against the persisted secret state. ``submission``: ``{"answers": [{"question_id", "selected_key"}]}``. Only issued question_ids count; duplicates keep the first. Combo is evaluated in ISSUED order. """ answers_raw = submission.get("answers") if isinstance(submission, dict) else None by_qid: dict[str, str] = {} if isinstance(answers_raw, list): for a in answers_raw: if not isinstance(a, dict): continue qid = str(a.get("question_id") or "") if qid and qid not in by_qid: by_qid[qid] = str(a.get("selected_key") or "") score = 0 combo = 0 best_combo = 0 correct_count = 0 answered_count = 0 breakdown: list[dict[str, Any]] = [] for item in payload.get("items", []): qid = item["question_id"] selected = _normalize_keys(by_qid.get(qid)) answered = bool(selected) is_correct = answered and selected == item["correct_key"] gained = 0 if answered: answered_count += 1 if is_correct: correct_count += 1 combo += 1 best_combo = max(best_combo, combo) gained = BASE_POINTS + COMBO_STEP * min(combo - 1, COMBO_CAP) score += gained else: combo = 0 breakdown.append({ "question_id": qid, "prompt": item["prompt"], "options": item["options"], "category": item.get("category"), "selected_key": selected or None, "correct_key": item["correct_key"], "is_correct": is_correct, "explanation": item["explanation"], "gained": gained, }) return GradeResult( score=score, detail={ "correct_count": correct_count, "answered_count": answered_count, "question_count": len(payload.get("items", [])), "best_combo": best_combo, "variant": payload.get("variant", MIXED), "elapsed_ms": elapsed_ms, "breakdown": breakdown, }, ) __all__ = ["build", "grade", "TIME_LIMIT_MS", "QUESTION_COUNT", "MIXED"]