"""Eleusis game state machine — pure logic, runs inside Pyodide. Two variants share one engine: - "hf": the 26-rule benchmark eval set; 10 turns; played card is replaced by a draw (the recorded model games are rl4-consistency-step30). - "small": the simple-rules era; 6 held-out rules x 4 deals; 8 turns; no redraws (recorded games are the step-90 LoRA of the hosted run). All rule evaluation and guess-checking uses the engine extracted verbatim from the eleusis environment, so the human faces exactly the checks the model faced. """ import json import random from cards import parse_card, deck from engine import compile_python_rule, guess_matches_target VARIANTS = { "hf": {"max_turns": 10, "redraw": True}, "small": {"max_turns": 8, "redraw": False}, } GAMES = {} STATE = None def load_games(variant: str, payload: str) -> int: GAMES[variant] = json.loads(payload) return len(GAMES[variant]) def new_game(variant: str) -> str: global STATE cfg = VARIANTS[variant] games = GAMES[variant] task_id = random.choice(list(games.keys())) info = games[task_id] starter, hand, draws = info["starter"], list(info["hand"]), list(info.get("draws", [])) if cfg["redraw"]: used = {starter, *hand, *draws} fallback = [c for c in deck() if c not in used] random.Random(task_id).shuffle(fallback) draws = draws + fallback STATE = { "variant": variant, "task_id": task_id, "hand": hand, "mainline": [starter], "board": [{"card": starter, "rejects": []}], "turn": 0, "draws": draws, "solved": False, "over": False, "log": [], } return _public_state() def _info(): return GAMES[STATE["variant"]][STATE["task_id"]] def _cfg(): return VARIANTS[STATE["variant"]] def play_turn(selected_card: str, guess_text: str, no_play: bool) -> str: if STATE is None: return json.dumps({"error": "no game"}) s = STATE cfg = _cfg() if s["over"]: return _public_state() if not no_play and (not selected_card or selected_card not in s["hand"]): return _public_state("Pick a card from your hand first (or declare no-play).") target = compile_python_rule(_info()["code"]) s["turn"] += 1 mainline_cards = [parse_card(c) for c in s["mainline"]] guess = (guess_text or "").strip() guess_correct = bool(guess) and guess_matches_target(guess, target) guess_error = "" if guess and not guess_correct: try: compile_python_rule(guess) except Exception as exc: guess_error = f"guess did not compile: {exc}" if no_play: would_accept = any(target(parse_card(c), mainline_cards) for c in s["hand"]) result = "missed_play" if would_accept else "correct_no_play" card = None else: card = selected_card accepted = target(parse_card(card), mainline_cards) s["hand"].remove(card) if accepted: s["mainline"].append(card) s["board"].append({"card": card, "rejects": []}) result = "accepted" else: s["board"][-1]["rejects"].append(card) result = "rejected" if cfg["redraw"] and s["draws"]: s["hand"].append(s["draws"].pop(0)) s["log"].append({"card": card, "result": result, "guess": guess, "guess_correct": guess_correct, "guess_error": guess_error}) if guess_correct: s["solved"], s["over"] = True, True elif s["turn"] >= cfg["max_turns"]: s["over"] = True return _public_state() def give_up() -> str: if STATE is None: return json.dumps({"error": "no game"}) STATE["over"] = True return _public_state() def _public_state(message: str = "") -> str: assert STATE is not None s: dict = STATE out = {k: s[k] for k in ("variant", "hand", "board", "turn", "solved", "over", "log")} out["max_turns"] = _cfg()["max_turns"] out["message"] = message info = _info() out["model_solved_count"] = sum(e["solved"] for e in info["episodes"]) out["model_attempts"] = [ {"solved": e["solved"], "turns_used": e["turns_used"]} for e in info["episodes"] ] if s["over"]: out["rule_label"] = info["label"] out["rule_code"] = info["code"] solved_turns = [e["turns_used"] for e in info["episodes"] if e["solved"]] out["model_best_turns"] = min(solved_turns) if solved_turns else None out["score"] = (_cfg()["max_turns"] + 1 - s["turn"]) if s["solved"] else 0 return json.dumps(out) def reveal_attempt(idx: int) -> str: if STATE is None: return json.dumps({"error": "no game"}) info = _info() ep = info["episodes"][int(idx)] return json.dumps({ "index": int(idx), "solved": ep["solved"], "turns_used": ep["turns_used"], "starter": info["starter"], "turns": ep["turns"], "model_solved_count": sum(e["solved"] for e in info["episodes"]), })