"""舒尔特方格 (Schulte Grid) — find 1→N in order, fast. A classic attention-training grid: the numbers 1..N are shuffled and the player taps them in ascending order. The server generates the layout and scores from ITS OWN clock (started_at → submit) plus the client-reported wrong taps — the client cannot report a time. **Variants** (difficulty / shape — chosen at round start): * ``classic`` — 5×5 grid (25), the original. * ``grid6`` — 6×6 grid (36). * ``grid7`` — 7×7 grid (49), for the sharp-eyed. * ``rotating`` — 5×5, but the board slowly rotates as you play (a purely visual difficulty twist rendered client-side; the server still verifies the same 1..25 tap order). * ``rings`` — the numbers sit on concentric rings instead of a grid, so your eye has to sweep in circles. Scoring: ``max(0, base - 20×seconds - 15×wrong_taps)`` on completion, where ``base`` scales with the cell count (72×cells → 5×5 keeps the classic 1800), so a bigger board is worth more. An incomplete round scores proportionally on verified progress. The layout is public (the player sees the board), but the server copy is the truth submissions are verified against. """ 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 TIME_LIMIT_MS = 90_000 GRID_SIZE = 5 # classic — kept for back-compat imports CELLS = GRID_SIZE * GRID_SIZE BASE_PER_CELL = 72.0 # 25 cells → 1800 (classic base unchanged) TIME_PENALTY_PER_S = 20.0 WRONG_TAP_PENALTY = 15 PROGRESS_POINTS = 20 # per verified correct tap when incomplete # Variant catalog. ``cells`` is authoritative for scoring/verification. _VARIANTS: dict[str, dict] = { "classic": {"shape": "grid", "grid_size": 5, "cells": 25}, "grid6": {"shape": "grid", "grid_size": 6, "cells": 36}, "grid7": {"shape": "grid", "grid_size": 7, "cells": 49}, "rotating": {"shape": "grid", "grid_size": 5, "cells": 25, "rotating": True}, "rings": {"shape": "rings", "ring_sizes": [6, 10, 14], "cells": 30}, } 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) cells = spec["cells"] rng = random.SystemRandom() layout = list(range(1, cells + 1)) rng.shuffle(layout) public: dict = {"layout": layout, "cells": cells, "shape": spec["shape"], "variant": key} if spec["shape"] == "grid": public["grid_size"] = spec["grid_size"] if spec.get("rotating"): public["rotating"] = True else: # rings public["ring_sizes"] = spec["ring_sizes"] # Layout is not secret (the player sees the board), but the server copy # is the truth submissions are verified against. return BuildResult( payload={"layout": layout, "cells": cells}, public=public, variant=key, ) def grade(payload: dict, submission: dict, elapsed_ms: int) -> GradeResult: """``submission``: ``{"taps": [1, 2, 3, …], "wrong_taps": 2}``. ``taps`` are the CORRECT taps in the order made (the client only advances on a correct tap); we verify it is a prefix of 1..N against the issued layout. ``wrong_taps`` is client-reported and only ever lowers the score. """ # ``cells`` is authoritative from the persisted round; fall back to the # issued layout length (older rounds pre-variant) then the classic 25. layout = payload.get("layout") if isinstance(payload, dict) else None cells = 0 if isinstance(payload, dict): try: cells = int(payload.get("cells") or 0) except (TypeError, ValueError): cells = 0 if cells <= 0: cells = len(layout) if isinstance(layout, list) and layout else CELLS taps_raw = submission.get("taps") if isinstance(submission, dict) else None taps: list[int] = [] if isinstance(taps_raw, list): for v in taps_raw: try: taps.append(int(v)) except (TypeError, ValueError): break try: wrong_taps = max(0, int(submission.get("wrong_taps", 0))) except (TypeError, ValueError): wrong_taps = 0 wrong_taps = min(wrong_taps, 500) # sanity clamp # Verified progress = the longest prefix equal to 1,2,3,… progress = 0 for i, v in enumerate(taps): if v == i + 1: progress += 1 else: break progress = min(progress, cells) completed = progress == cells seconds = elapsed_ms / 1000.0 base = BASE_PER_CELL * cells if completed: score = max(0, round(base - TIME_PENALTY_PER_S * seconds - WRONG_TAP_PENALTY * wrong_taps)) # A completed board is always worth something. score = max(score, 50) else: score = max(0, progress * PROGRESS_POINTS - WRONG_TAP_PENALTY * wrong_taps) return GradeResult( score=score, detail={ "completed": completed, "progress": progress, "cells": cells, "wrong_taps": wrong_taps, "elapsed_ms": elapsed_ms, }, ) __all__ = [ "build", "grade", "TIME_LIMIT_MS", "GRID_SIZE", "CELLS", "VARIANT_KEYS", "DEFAULT_VARIANT", ]