"""记忆翻牌 (Memory Match) — flip pairs on a variable-size board. The server shuffles ``pairs`` symbol pairs into ``2×pairs`` positions. The layout IS shipped to the client (a card flip must render its symbol without a round-trip), but grading still replays the submitted flip attempts against the SERVER's copy — the client reports moves, never a score, and invalid/duplicate/out-of-range attempts are discarded. Like every relax-category game the score is primarily a personal metric; the time + extra-attempt penalties make scripted "perfect" runs pointless rather than profitable. **Variants** (difficulty — chosen at round start; ``cols`` is a display hint so the client renders the right grid width, ``pairs`` is authoritative for scoring/verification): * ``easy`` — 4×3 board (6 pairs), a gentle warm-up. * ``classic`` — 4×4 board (8 pairs), the original default. * ``hard`` — 5×4 board (10 pairs). * ``expert`` — 6×6 board (18 pairs), for the sharp-memoried. """ from __future__ import annotations import random from sqlalchemy.ext.asyncio import AsyncSession from app.domains.arcade.catalog import BuildResult, GradeResult from app.identity.person_models import IdentityPerson # Generous single limit across every variant (mirrors schulte's one-timer # design); expert's 18 pairs are feasible without the timer trivialising easy. TIME_LIMIT_MS = 150_000 PAIRS = 8 # classic — kept for back-compat imports CELLS = PAIRS * 2 MATCH_POINTS = 150 EXTRA_ATTEMPT_PENALTY = 20 TIME_PENALTY_PER_S = 4.0 COMPLETION_BONUS_PER_PAIR = 25 # 8 pairs → 200 (classic completion bonus unchanged) # Security-flavored symbol set (pure cosmetics — the game itself is relax). # Must hold at least ``max(pairs)`` distinct symbols (expert needs 18). _SYMBOLS = [ "🛡️", "🔑", "🔒", "🕵️", "📡", "💾", "🧯", "🚨", "🧬", "🛰️", "🔐", "🖥️", "📱", "🧑‍💻", "🌐", "⚠️", "🪪", "📶", "🔎", "🧿", ] # Variant catalog. ``pairs`` is authoritative for scoring; ``cols`` is the # grid width the client should render (rows = 2×pairs / cols). _VARIANTS: dict[str, dict] = { "easy": {"pairs": 6, "cols": 4}, # 4×3 "classic": {"pairs": 8, "cols": 4}, # 4×4 "hard": {"pairs": 10, "cols": 5}, # 5×4 "expert": {"pairs": 18, "cols": 6}, # 6×6 } DEFAULT_VARIANT = "classic" VARIANT_KEYS: tuple[str, ...] = tuple(_VARIANTS) def _resolve_variant(variant: str | None) -> tuple[str, dict]: key = variant if variant in _VARIANTS else DEFAULT_VARIANT return key, _VARIANTS[key] async def build(db: AsyncSession, person: IdentityPerson, variant: str | None = None) -> BuildResult: # noqa: ARG001 key, spec = _resolve_variant(variant) pairs = spec["pairs"] cols = spec["cols"] rng = random.SystemRandom() symbols = rng.sample(_SYMBOLS, pairs) * 2 rng.shuffle(symbols) return BuildResult( # ``pairs`` persisted so grade is variant-aware for older/any round. payload={"layout": symbols, "pairs": pairs}, public={"layout": symbols, "cells": pairs * 2, "pairs": pairs, "cols": cols, "variant": key}, variant=key, ) def grade(payload: dict, submission: dict, elapsed_ms: int) -> GradeResult: """``submission``: ``{"attempts": [[0, 5], [3, 9], …]}`` — pairs of positions in the order flipped. Replayed server-side for truth.""" layout: list[str] = list(payload.get("layout", [])) # ``pairs`` is authoritative from the persisted round; fall back to the # issued layout length (older rounds pre-variant) then the classic 8. pairs = 0 if isinstance(payload, dict): try: pairs = int(payload.get("pairs") or 0) except (TypeError, ValueError): pairs = 0 if pairs <= 0: pairs = (len(layout) // 2) if layout else PAIRS attempts_raw = submission.get("attempts") if isinstance(submission, dict) else None matched: set[int] = set() attempts = 0 matches = 0 if isinstance(attempts_raw, list): for att in attempts_raw[:600]: # sanity clamp (scales with board size) if not (isinstance(att, (list, tuple)) and len(att) == 2): continue try: a, b = int(att[0]), int(att[1]) except (TypeError, ValueError): continue if a == b or not (0 <= a < len(layout)) or not (0 <= b < len(layout)): continue if a in matched or b in matched: continue attempts += 1 if layout[a] == layout[b]: matched.update((a, b)) matches += 1 completed = matches == pairs seconds = elapsed_ms / 1000.0 score = matches * MATCH_POINTS score -= max(0, attempts - matches) * EXTRA_ATTEMPT_PENALTY score -= round(TIME_PENALTY_PER_S * seconds) if completed: score += COMPLETION_BONUS_PER_PAIR * pairs score = max(0, score) return GradeResult( score=score, detail={ "completed": completed, "matches": matches, "pairs": pairs, "attempts": attempts, "elapsed_ms": elapsed_ms, }, ) __all__ = ["build", "grade", "TIME_LIMIT_MS", "PAIRS", "CELLS", "VARIANT_KEYS", "DEFAULT_VARIANT"]