import hashlib import html import json import random import re import time from typing import Any, Dict, List, Tuple import gradio as gr try: import spaces except Exception: class _SpacesFallback: @staticmethod def GPU(duration=25): def decorator(fn): return fn return decorator spaces = _SpacesFallback() try: import torch from transformers import AutoModelForCausalLM, AutoTokenizer except Exception as exc: torch = None AutoModelForCausalLM = None AutoTokenizer = None IMPORT_ERROR = exc else: IMPORT_ERROR = None MODEL_ID = "openbmb/MiniCPM5-1B" MAX_ROUNDS = 5 MAX_SCORE_PER_ROUND = 20 MAX_TOTAL_SCORE = MAX_ROUNDS * MAX_SCORE_PER_ROUND tokenizer = None model = None MODEL_STATUS = "fallback" MODEL_ERROR = "" if IMPORT_ERROR is None: try: tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) model = AutoModelForCausalLM.from_pretrained( MODEL_ID, torch_dtype="auto", device_map="auto", ) model.eval() MODEL_STATUS = "model loaded" except Exception as exc: MODEL_ERROR = f"{type(exc).__name__}: {exc}" MODEL_STATUS = "fallback" else: MODEL_ERROR = f"{type(IMPORT_ERROR).__name__}: {IMPORT_ERROR}" FALLBACK_TITLES = [ "The Moon Forgot Its Shoes", "A Teacup Is Haunting City Hall", "The Library Wants a Sandwich", "A Tiny Volcano Needs Manners", "The Clocktower Is Sneezing Glitter", ] FALLBACK_ITEMS = [ ["velvet magnet", "borrowed thunder", "pocket ladder", "polite jelly"], ["map of yesterday", "tin crown", "soup compass", "apology balloon"], ["sleepy key", "confetti wrench", "moonlit receipt", "bubble helmet"], ["rubber prophecy", "miniature foghorn", "cake shovel", "friendly spark"], ["paper comet", "clock seed", "sock telescope", "whisper net"], ] BADGES = [ "Pocket Mythmaker", "Errand Acrobat", "Tiny Titan Tactician", "Chaos Diplomat", "Snack-Sized Sorcerer", "Impossible Intern", ] def stable_rng(seed: int, salt: str) -> random.Random: digest = hashlib.sha256(f"{seed}:{salt}".encode("utf-8")).hexdigest() return random.Random(int(digest[:12], 16)) def initial_state() -> Dict[str, Any]: return { "round_number": 0, "total_score": 0, "current_challenge": None, "history": [], "seed": int(time.time() * 1000) % 1_000_000, "game_over": False, "last_status": "Ready. Press Start Game.", } def escape(value: Any) -> str: return html.escape(str(value), quote=True) def extract_json(text: str) -> Dict[str, Any]: if not text: raise ValueError("empty model response") cleaned = text.strip() cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned, flags=re.IGNORECASE) cleaned = re.sub(r"\s*```$", "", cleaned) try: parsed = json.loads(cleaned) if isinstance(parsed, dict): return parsed except json.JSONDecodeError: pass match = re.search(r"\{.*\}", cleaned, flags=re.DOTALL) if not match: raise ValueError("no JSON object found") parsed = json.loads(match.group(0)) if not isinstance(parsed, dict): raise ValueError("JSON payload is not an object") return parsed def clamp_score(value: Any) -> int: try: score = int(round(float(value))) except Exception: score = 0 return max(0, min(MAX_SCORE_PER_ROUND, score)) def normalize_challenge(data: Dict[str, Any], seed: int, round_number: int) -> Dict[str, Any]: fallback = fallback_challenge(seed, round_number) items = data.get("items", fallback["items"]) if not isinstance(items, list): items = fallback["items"] items = [str(item).strip() for item in items if str(item).strip()][:4] while len(items) < 4: items.append(fallback["items"][len(items)]) return { "title": str(data.get("title") or fallback["title"]).strip()[:90], "scene": str(data.get("scene") or fallback["scene"]).strip()[:260], "goal": str(data.get("goal") or fallback["goal"]).strip()[:180], "items": items, "hidden_rule": str(data.get("hidden_rule") or fallback["hidden_rule"]).strip()[:180], "vibe": str(data.get("vibe") or fallback["vibe"]).strip()[:80], "judge_hint": str(data.get("judge_hint") or fallback["judge_hint"]).strip()[:140], } def normalize_judgment(data: Dict[str, Any], seed: int, round_number: int, move: str) -> Dict[str, Any]: fallback = fallback_judgment(seed, round_number, move) return { "verdict": str(data.get("verdict") or fallback["verdict"]).strip()[:240], "score_delta": clamp_score(data.get("score_delta", fallback["score_delta"])), "badge": str(data.get("badge") or fallback["badge"]).strip()[:60], "twist": str(data.get("twist") or fallback["twist"]).strip()[:160], "next_hook": str(data.get("next_hook") or fallback["next_hook"]).strip()[:160], } def fallback_challenge(seed: int, round_number: int) -> Dict[str, Any]: rng = stable_rng(seed, f"challenge:{round_number}") title = FALLBACK_TITLES[(round_number - 1) % len(FALLBACK_TITLES)] item_set = FALLBACK_ITEMS[(round_number - 1) % len(FALLBACK_ITEMS)] goal_bits = [ "deliver a secret apology before the parade learns math", "convince a grumpy landmark to stop floating sideways", "trade a rumor for a harmless miracle by sunset", "escort a nervous sparkle through a very official doorway", "make breakfast for an idea that has not been invented yet", ] hidden_rules = [ "Awards extra points for using the least practical item as the key tool.", "Awards extra points for kindness toward the weirdest character.", "Awards extra points for turning a problem into a tiny ceremony.", "Awards extra points for avoiding brute force and choosing misdirection.", "Awards extra points for making the errand funnier than necessary.", ] return { "title": title, "scene": "A brass bell rings inside a shoebox arcade. The Quest Clerk slides over a stamp pad, a fizzy map, and a problem that refuses to be normal.", "goal": goal_bits[rng.randrange(len(goal_bits))], "items": item_set, "hidden_rule": hidden_rules[rng.randrange(len(hidden_rules))], "vibe": rng.choice(["jolly mischief", "cozy chaos", "button-mashing folklore", "snack-sized epic"]), "judge_hint": "The judge likes clever item use, cheerful weirdness, and one clean sentence.", } def fallback_judgment(seed: int, round_number: int, move: str) -> Dict[str, Any]: rng = stable_rng(seed, f"judge:{round_number}:{move.lower().strip()}") word_count = len(re.findall(r"\w+", move)) has_item_bonus = min(6, word_count // 3) score = max(5, min(20, 8 + has_item_bonus + rng.randrange(0, 7))) return { "verdict": "The Quest Clerk squints, stamps the paperwork upside down, and accepts the plan as legally whimsical.", "score_delta": score, "badge": BADGES[rng.randrange(len(BADGES))], "twist": rng.choice([ "The errand gets easier after everyone agrees to whisper in rhymes.", "A tiny crowd applauds from inside a coat pocket.", "The least useful object becomes suspiciously essential.", "The paperwork sprouts legs and files itself.", ]), "next_hook": "Another bell rings. The next impossible errand is already tapping its foot.", } def challenge_prompt(seed: int, round_number: int) -> str: return f""" Create round {round_number} of 5 for Tiny Quest Forge, a joyful AI-native mini-game. Invent a whimsical impossible errand, exactly 4 strange item cards, one hidden scoring rule, a vibe, and one public judge hint. Use seed {seed} for variety. Return ONLY JSON with this exact shape: {{ "title": "...", "scene": "...", "goal": "...", "items": ["...", "...", "...", "..."], "hidden_rule": "...", "vibe": "...", "judge_hint": "..." }} Keep every field short, playful, and concrete. """.strip() def judge_prompt(challenge: Dict[str, Any], move: str) -> str: return f""" Judge this Tiny Quest Forge move. The player wrote one sentence. Challenge: Title: {challenge["title"]} Scene: {challenge["scene"]} Goal: {challenge["goal"]} Items: {", ".join(challenge["items"])} Hidden scoring rule: {challenge["hidden_rule"]} Judge hint shown to player: {challenge["judge_hint"]} Player move: {move} Return ONLY JSON with this exact shape: {{ "verdict": "...", "score_delta": 0, "badge": "...", "twist": "...", "next_hook": "..." }} score_delta must be an integer from 0 to 20. Reward clever item use, joyful specificity, and satisfying the hidden rule. """.strip() @spaces.GPU(duration=25) def generate_text(prompt: str, max_new_tokens: int = 180) -> str: if model is None or tokenizer is None or torch is None: raise RuntimeError(MODEL_ERROR or "model unavailable") messages = [ { "role": "system", "content": "You are the Tiny Quest Forge game engine. You return compact valid JSON only.", }, {"role": "user", "content": prompt}, ] try: try: input_ids = tokenizer.apply_chat_template( messages, tokenize=True, enable_thinking=False, add_generation_prompt=True, return_tensors="pt", ) except TypeError: input_ids = tokenizer.apply_chat_template( messages, tokenize=True, add_generation_prompt=True, return_tensors="pt", ) device = next(model.parameters()).device input_ids = input_ids.to(device) with torch.no_grad(): outputs = model.generate( input_ids, max_new_tokens=min(max_new_tokens, 220), do_sample=True, temperature=0.78, top_p=0.92, repetition_penalty=1.05, pad_token_id=tokenizer.eos_token_id, ) new_tokens = outputs[0][input_ids.shape[-1]:] return tokenizer.decode(new_tokens, skip_special_tokens=True).strip() finally: if torch is not None and torch.cuda.is_available(): torch.cuda.empty_cache() def create_challenge(seed: int, round_number: int) -> Tuple[Dict[str, Any], str]: try: raw = generate_text(challenge_prompt(seed, round_number), max_new_tokens=190) data = extract_json(raw) return normalize_challenge(data, seed, round_number), "model loaded" except Exception as exc: return fallback_challenge(seed, round_number), f"fallback: {type(exc).__name__}" def judge_move(challenge: Dict[str, Any], move: str, seed: int, round_number: int) -> Tuple[Dict[str, Any], str]: try: raw = generate_text(judge_prompt(challenge, move), max_new_tokens=170) data = extract_json(raw) return normalize_judgment(data, seed, round_number, move), "model loaded" except Exception as exc: return fallback_judgment(seed, round_number, move), f"fallback: {type(exc).__name__}" def rank_title(score: int) -> str: if score >= 90: return "Grand Errand Wizard" if score >= 70: return "Pocket Quest Champion" if score >= 50: return "Certified Odd-Job Hero" if score >= 30: return "Promising Button Masher" return "Apprentice of Tiny Chaos" def share_card(state: Dict[str, Any]) -> str: badges = [entry["judgment"]["badge"] for entry in state.get("history", []) if entry.get("judgment", {}).get("badge")] badge_text = ", ".join(badges[:5]) if badges else "no badges yet" return ( "Tiny Quest Forge result\n" f"Score: {state.get('total_score', 0)}/{MAX_TOTAL_SCORE}\n" f"Rank: {rank_title(state.get('total_score', 0))}\n" f"Badges: {badge_text}\n" "I solved five impossible errands with a 1B local model." ) def round_pills(round_number: int, history_count: int, game_over: bool) -> str: pills = [] for idx in range(1, MAX_ROUNDS + 1): if game_over or idx <= history_count: cls = "done" label = f"R{idx} done" elif idx == round_number: cls = "active" label = f"R{idx} live" else: cls = "locked" label = f"R{idx}" pills.append(f'{escape(label)}') return "".join(pills) def render_history(history: List[Dict[str, Any]]) -> str: if not history: return '
Badges and judged errands will stack here.
' rows = [] for entry in history[-5:]: challenge = entry["challenge"] judgment = entry["judgment"] rows.append( f"""
{escape(challenge["title"])} +{escape(judgment["score_delta"])} pts

{escape(judgment["verdict"])}

{escape(judgment["badge"])}
Rule revealed: {escape(challenge["hidden_rule"])}
""" ) return "".join(rows) def render_board(state: Dict[str, Any]) -> str: state = state or initial_state() total = int(state.get("total_score", 0)) progress = max(0, min(100, round((total / MAX_TOTAL_SCORE) * 100))) round_number = int(state.get("round_number", 0)) history = state.get("history", []) game_over = bool(state.get("game_over", False)) challenge = state.get("current_challenge") status = state.get("last_status", "Ready.") if challenge: item_html = "".join(f'{escape(item)}' for item in challenge["items"]) challenge_html = f"""
Round {escape(round_number)} errand | {escape(challenge["vibe"])}

{escape(challenge["title"])}

{escape(challenge["scene"])}

Goal{escape(challenge["goal"])}
{item_html}

{escape(challenge["judge_hint"])}

""" elif game_over: challenge_html = "" else: challenge_html = """
Ready at the tiny counter

Press Start Game

Five impossible errands are waiting. Each one gives you four strange item cards and one sentence to make trouble useful.

borrowed thunder sock telescope soup compass paper comet
""" final_html = "" if game_over: card = share_card(state) badges = [entry["judgment"]["badge"] for entry in history if entry.get("judgment", {}).get("badge")] badges_html = "".join(f'{escape(badge)}' for badge in badges) or 'No badges' final_html = f"""
Final result

{escape(rank_title(total))}

{escape(total)}/{MAX_TOTAL_SCORE}
{badges_html}
{escape(card)}
""" return f"""
Tiny Quest Forge

Five tiny errands. One impossible sentence each.

Score {escape(total)}/{MAX_TOTAL_SCORE}
{round_pills(round_number, len(history), game_over)}
{escape(status)}
{challenge_html} {final_html}
Quest ledger
{render_history(history)}
""" def status_html(message: str) -> str: return f'
{escape(message)}
' def start_game() -> Tuple[Dict[str, Any], str, str, Any]: state = initial_state() state["round_number"] = 1 state["last_status"] = "Generating round 1..." yield state, render_board(state), status_html(state["last_status"]), gr.update(value="") challenge, source = create_challenge(state["seed"], 1) state["current_challenge"] = challenge state["last_status"] = f"{source}. Round 1 is ready." yield state, render_board(state), status_html(state["last_status"]), gr.update(value="") def reset_game() -> Tuple[Dict[str, Any], str, str, Any]: state = initial_state() return state, render_board(state), status_html(state["last_status"]), gr.update(value="") def new_round(state: Dict[str, Any]) -> Tuple[Dict[str, Any], str, str, Any]: state = state or initial_state() if state.get("game_over"): state["last_status"] = "Final card is ready. Reset to play again." yield state, render_board(state), status_html(state["last_status"]), gr.update() return if not state.get("history"): yield from start_game() return if len(state["history"]) >= MAX_ROUNDS: state["game_over"] = True state["last_status"] = "Final score locked." yield state, render_board(state), status_html(state["last_status"]), gr.update() return if state.get("current_challenge") and len(state["history"]) < state.get("round_number", 0): state["last_status"] = "Submit this move before drawing another errand." yield state, render_board(state), status_html(state["last_status"]), gr.update() return next_round = len(state["history"]) + 1 state["round_number"] = next_round state["current_challenge"] = None state["last_status"] = f"Generating round {next_round}..." yield state, render_board(state), status_html(state["last_status"]), gr.update(value="") challenge, source = create_challenge(state["seed"], next_round) state["current_challenge"] = challenge state["last_status"] = f"{source}. Round {next_round} is ready." yield state, render_board(state), status_html(state["last_status"]), gr.update(value="") def submit_move(move: str, state: Dict[str, Any]) -> Tuple[Dict[str, Any], str, str, Any]: state = state or initial_state() move = (move or "").strip() if state.get("game_over"): state["last_status"] = "Game complete. Reset to play another run." yield state, render_board(state), status_html(state["last_status"]), gr.update() return if not state.get("current_challenge"): state["last_status"] = "Start a game before submitting a move." yield state, render_board(state), status_html(state["last_status"]), gr.update() return if not move: state["last_status"] = "Type one sentence before submitting." yield state, render_board(state), status_html(state["last_status"]), gr.update() return if len(re.findall(r"[.!?]", move)) > 2 or len(move) > 220: move = move[:220].strip() round_number = int(state.get("round_number", 1)) state["last_status"] = "Generating judgment..." yield state, render_board(state), status_html(state["last_status"]), gr.update() judgment, source = judge_move(state["current_challenge"], move, state["seed"], round_number) judgment["score_delta"] = clamp_score(judgment["score_delta"]) state["total_score"] = int(state.get("total_score", 0)) + judgment["score_delta"] state["history"].append( { "round": round_number, "move": move, "challenge": state["current_challenge"], "judgment": judgment, } ) if len(state["history"]) >= MAX_ROUNDS: state["game_over"] = True state["current_challenge"] = None state["last_status"] = f"{source}. Final result forged." else: state["current_challenge"] = None state["last_status"] = f"{source}. Badge earned. Press New Round." yield state, render_board(state), status_html(state["last_status"]), gr.update(value="") CSS = """ :root { --tqf-ink: #17141f; --tqf-paper: #fff9e8; --tqf-panel: #fff3c6; --tqf-line: #2d2440; --tqf-indigo: #4831a8; --tqf-violet: #7b3fa3; --tqf-yellow: #ffd84d; --tqf-mint: #5de1b5; --tqf-coral: #ff8066; } .gradio-container { background: linear-gradient(135deg, rgba(255,216,77,.18), rgba(93,225,181,.14)), repeating-linear-gradient(0deg, #f7f0dc 0, #f7f0dc 18px, #f3e7c4 19px); color: var(--tqf-ink); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } .tqf-shell { width: min(1120px, calc(100vw - 28px)); margin: 0 auto; padding: 22px 0 18px; } .tqf-hero, .tqf-status, .tqf-band { border: 3px solid var(--tqf-line); box-shadow: 6px 6px 0 var(--tqf-line); } .tqf-hero { display: flex; justify-content: space-between; gap: 18px; align-items: stretch; background: var(--tqf-indigo); color: white; padding: 20px; } .tqf-brand { width: fit-content; background: var(--tqf-yellow); color: var(--tqf-ink); border: 2px solid var(--tqf-line); padding: 5px 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0; } .tqf-hero h1 { margin: 12px 0 0; max-width: 690px; font-size: clamp(2rem, 4.5vw, 4rem); line-height: 1; letter-spacing: 0; } .tqf-scorebox { min-width: 180px; background: var(--tqf-paper); color: var(--tqf-ink); border: 2px solid var(--tqf-line); padding: 12px; display: grid; align-content: center; gap: 6px; } .tqf-scorebox span, .tqf-kicker { font-size: .78rem; font-weight: 900; text-transform: uppercase; letter-spacing: 0; } .tqf-scorebox strong { font-size: 2rem; line-height: 1; } .tqf-meter { height: 14px; border: 2px solid var(--tqf-line); background: white; overflow: hidden; } .tqf-meter i { display: block; height: 100%; background: linear-gradient(90deg, var(--tqf-mint), var(--tqf-yellow), var(--tqf-coral)); } .tqf-status { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin: 14px 0; padding: 10px; background: #fefefe; font-weight: 800; } .tqf-rounds { display: flex; gap: 6px; flex-wrap: wrap; } .tqf-pill, .tqf-chip, .tqf-badge { display: inline-flex; align-items: center; min-height: 30px; border: 2px solid var(--tqf-line); color: var(--tqf-ink); font-weight: 850; line-height: 1.1; } .tqf-pill { padding: 4px 8px; background: #ece8f7; } .tqf-pill.active { background: var(--tqf-yellow); } .tqf-pill.done { background: var(--tqf-mint); } .tqf-pill.locked { opacity: .62; } .tqf-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(280px, .75fr); gap: 16px; align-items: start; } .tqf-band { background: var(--tqf-paper); padding: 18px; } .tqf-board h2, .tqf-final h2 { margin: 8px 0 10px; font-size: clamp(1.7rem, 3vw, 2.7rem); line-height: 1.05; letter-spacing: 0; } .tqf-scene { margin: 0 0 14px; font-size: 1.05rem; line-height: 1.45; } .tqf-goal { display: grid; gap: 5px; background: white; border: 2px dashed var(--tqf-violet); padding: 12px; font-weight: 750; } .tqf-goal span { color: var(--tqf-violet); text-transform: uppercase; font-size: .75rem; font-weight: 950; } .tqf-items, .tqf-badges { display: flex; gap: 8px; flex-wrap: wrap; margin: 14px 0 10px; } .tqf-chip { background: #fefefe; padding: 8px 10px; } .tqf-chip:nth-child(2n) { background: #e6fff6; } .tqf-chip:nth-child(3n) { background: #fff0ed; } .tqf-hint { margin: 10px 0 0; font-weight: 800; color: #473a60; } .tqf-ledger { min-height: 280px; } .tqf-empty { margin-top: 12px; color: #5d526c; font-weight: 700; } .tqf-log { border-top: 2px solid var(--tqf-line); padding: 12px 0; } .tqf-log-top { display: flex; justify-content: space-between; gap: 10px; font-weight: 900; } .tqf-log p { margin: 8px 0; line-height: 1.35; } .tqf-log small { display: block; margin-top: 8px; color: #5d526c; line-height: 1.35; } .tqf-badge { background: var(--tqf-yellow); padding: 5px 8px; width: fit-content; } .tqf-final { margin-top: 16px; background: #e9fff6; } .tqf-final-score { font-size: 3rem; font-weight: 950; line-height: 1; } .tqf-final pre { white-space: pre-wrap; overflow-wrap: anywhere; background: white; border: 2px solid var(--tqf-line); padding: 12px; font-weight: 750; } .tqf-mini-status { width: min(1120px, calc(100vw - 28px)); margin: 0 auto 10px; color: #312747; font-weight: 850; } #move_box textarea { min-height: 78px; border: 3px solid var(--tqf-line); box-shadow: 4px 4px 0 var(--tqf-line); background: #fffef8; color: var(--tqf-ink); font-weight: 750; } #controls { width: min(1120px, calc(100vw - 28px)); margin: 0 auto; } #controls button { min-height: 46px; border: 2px solid var(--tqf-line) !important; box-shadow: 3px 3px 0 var(--tqf-line); font-weight: 950; } @media (max-width: 760px) { .tqf-shell { width: min(100% - 18px, 1120px); padding-top: 10px; } .tqf-hero, .tqf-status { flex-direction: column; align-items: stretch; } .tqf-grid { grid-template-columns: 1fr; } .tqf-scorebox { min-width: 0; } .tqf-hero, .tqf-status, .tqf-band { box-shadow: 4px 4px 0 var(--tqf-line); } } """ with gr.Blocks(css=CSS, title="Tiny Quest Forge", fill_width=True) as demo: game_state = gr.State(initial_state()) board = gr.HTML(render_board(initial_state())) status_line = gr.HTML(status_html(f"{MODEL_STATUS}. Press Start Game.")) with gr.Column(elem_id="controls"): move_box = gr.Textbox( label="One-sentence solution", placeholder="Example: I bribe the moon with the soup compass, then use the apology balloon as a tiny parachute.", lines=2, max_lines=3, max_length=220, elem_id="move_box", ) with gr.Row(): start_button = gr.Button("Start Game", variant="primary") submit_button = gr.Button("Submit Move", variant="huggingface") new_round_button = gr.Button("New Round") reset_button = gr.Button("Reset") outputs = [game_state, board, status_line, move_box] start_button.click(start_game, inputs=None, outputs=outputs, show_progress="minimal") submit_button.click(submit_move, inputs=[move_box, game_state], outputs=outputs, show_progress="minimal") move_box.submit(submit_move, inputs=[move_box, game_state], outputs=outputs, show_progress="minimal") new_round_button.click(new_round, inputs=game_state, outputs=outputs, show_progress="minimal") reset_button.click(reset_game, inputs=None, outputs=outputs, show_progress="hidden") if __name__ == "__main__": demo.queue(default_concurrency_limit=1).launch()