| """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"]), |
| }) |
|
|