| 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'<span class="tqf-pill {cls}">{escape(label)}</span>') |
| return "".join(pills) |
|
|
|
|
| def render_history(history: List[Dict[str, Any]]) -> str: |
| if not history: |
| return '<div class="tqf-empty">Badges and judged errands will stack here.</div>' |
|
|
| rows = [] |
| for entry in history[-5:]: |
| challenge = entry["challenge"] |
| judgment = entry["judgment"] |
| rows.append( |
| f""" |
| <div class="tqf-log"> |
| <div class="tqf-log-top"> |
| <strong>{escape(challenge["title"])}</strong> |
| <span>+{escape(judgment["score_delta"])} pts</span> |
| </div> |
| <p>{escape(judgment["verdict"])}</p> |
| <div class="tqf-badge">{escape(judgment["badge"])}</div> |
| <small>Rule revealed: {escape(challenge["hidden_rule"])}</small> |
| </div> |
| """ |
| ) |
| 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'<span class="tqf-chip">{escape(item)}</span>' for item in challenge["items"]) |
| challenge_html = f""" |
| <section class="tqf-band tqf-board"> |
| <div class="tqf-kicker">Round {escape(round_number)} errand | {escape(challenge["vibe"])}</div> |
| <h2>{escape(challenge["title"])}</h2> |
| <p class="tqf-scene">{escape(challenge["scene"])}</p> |
| <div class="tqf-goal"><span>Goal</span>{escape(challenge["goal"])}</div> |
| <div class="tqf-items" aria-label="Item cards">{item_html}</div> |
| <p class="tqf-hint">{escape(challenge["judge_hint"])}</p> |
| </section> |
| """ |
| elif game_over: |
| challenge_html = "" |
| else: |
| challenge_html = """ |
| <section class="tqf-band tqf-board tqf-idle"> |
| <div class="tqf-kicker">Ready at the tiny counter</div> |
| <h2>Press Start Game</h2> |
| <p class="tqf-scene">Five impossible errands are waiting. Each one gives you four strange item cards and one sentence to make trouble useful.</p> |
| <div class="tqf-items"> |
| <span class="tqf-chip">borrowed thunder</span> |
| <span class="tqf-chip">sock telescope</span> |
| <span class="tqf-chip">soup compass</span> |
| <span class="tqf-chip">paper comet</span> |
| </div> |
| </section> |
| """ |
|
|
| 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'<span class="tqf-badge">{escape(badge)}</span>' for badge in badges) or '<span class="tqf-badge">No badges</span>' |
| final_html = f""" |
| <section class="tqf-band tqf-final"> |
| <div class="tqf-kicker">Final result</div> |
| <h2>{escape(rank_title(total))}</h2> |
| <div class="tqf-final-score">{escape(total)}/{MAX_TOTAL_SCORE}</div> |
| <div class="tqf-badges">{badges_html}</div> |
| <pre>{escape(card)}</pre> |
| </section> |
| """ |
|
|
| return f""" |
| <main class="tqf-shell"> |
| <section class="tqf-hero"> |
| <div> |
| <div class="tqf-brand">Tiny Quest Forge</div> |
| <h1>Five tiny errands. One impossible sentence each.</h1> |
| </div> |
| <div class="tqf-scorebox"> |
| <span>Score</span> |
| <strong>{escape(total)}/{MAX_TOTAL_SCORE}</strong> |
| <div class="tqf-meter"><i style="width:{progress}%"></i></div> |
| </div> |
| </section> |
| <section class="tqf-status"> |
| <div class="tqf-rounds">{round_pills(round_number, len(history), game_over)}</div> |
| <span>{escape(status)}</span> |
| </section> |
| <div class="tqf-grid"> |
| <div> |
| {challenge_html} |
| {final_html} |
| </div> |
| <section class="tqf-band tqf-ledger"> |
| <div class="tqf-kicker">Quest ledger</div> |
| {render_history(history)} |
| </section> |
| </div> |
| </main> |
| """ |
|
|
|
|
| def status_html(message: str) -> str: |
| return f'<div class="tqf-mini-status">{escape(message)}</div>' |
|
|
|
|
| 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() |
|
|