"""色词反应 (Stroop) — does the word's MEANING match its ink color? The classic Stroop-effect reflex trainer: a color word (红/蓝/绿/黄…) renders in some ink color; the player answers 一致/不一致 as fast as possible. Trials are server-generated (word, ink) and graded here. NOTE: unlike quiz_rush, the (word, ink) pair the client needs to render IS the answer (match == word==ink), so a scripted client can derive a perfect run — and this game has no time component in the score. It is therefore marked ``competitive=False`` in the catalog: it tracks a PERSONAL best only and is excluded from the cross-player leaderboard. Same reasoning as 舒尔特/记忆翻牌 (their layout is public too). """ from __future__ import annotations import random from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.domains.arcade.catalog import BuildResult, GradeResult from app.identity.person_models import IdentityPerson TIME_LIMIT_MS = 45_000 TRIAL_COUNT = 20 BASE_POINTS = 80 COMBO_STEP = 15 COMBO_CAP = 5 # key → the frontend maps to its display hex + i18n word. Keys are # color slugs, language-neutral over the wire. COLORS = ["red", "blue", "green", "yellow", "purple", "orange"] async def build(db: AsyncSession, person: IdentityPerson, variant: str | None = None) -> BuildResult: # noqa: ARG001 rng = random.SystemRandom() trials_secret: list[dict[str, Any]] = [] trials_public: list[dict[str, Any]] = [] for i in range(TRIAL_COUNT): word = rng.choice(COLORS) # ~50/50 congruent so neither constant answer beats chance. if rng.random() < 0.5: ink = word else: ink = rng.choice([c for c in COLORS if c != word]) trials_secret.append({"index": i, "word": word, "ink": ink, "match": word == ink}) trials_public.append({"index": i, "word": word, "ink": ink}) return BuildResult( payload={"trials": trials_secret}, public={"trials": trials_public, "trial_count": TRIAL_COUNT}, ) def grade(payload: dict, submission: dict, elapsed_ms: int) -> GradeResult: """``submission``: ``{"answers": [{"index": 0, "match": true}, …]}``.""" raw = submission.get("answers") if isinstance(submission, dict) else None by_index: dict[int, bool] = {} if isinstance(raw, list): for a in raw: if not isinstance(a, dict): continue idx_raw = a.get("index") if idx_raw is None: continue try: idx = int(idx_raw) except (TypeError, ValueError): continue if idx not in by_index: by_index[idx] = bool(a.get("match")) score = 0 combo = 0 best_combo = 0 correct_count = 0 answered_count = 0 for trial in payload.get("trials", []): idx = trial["index"] answered = idx in by_index is_correct = answered and by_index[idx] == trial["match"] if answered: answered_count += 1 if is_correct: correct_count += 1 combo += 1 best_combo = max(best_combo, combo) score += BASE_POINTS + COMBO_STEP * min(combo - 1, COMBO_CAP) else: combo = 0 return GradeResult( score=score, detail={ "correct_count": correct_count, "answered_count": answered_count, "trial_count": len(payload.get("trials", [])), "best_combo": best_combo, "elapsed_ms": elapsed_ms, }, ) __all__ = ["build", "grade", "TIME_LIMIT_MS", "TRIAL_COUNT", "COLORS"]