Download app.py from build-small-hackathon/tiny-quest-forge: direct link, hf CLI and curl.
- Browser
- Download file 28.3 kB
-
https://huggingface.co/spaces/build-small-hackathon/tiny-quest-forge/resolve/main/app.py
- Command line
-
hf download hf://spaces/build-small-hackathon/tiny-quest-forge/app.py
-
curl -L -o app.py https://huggingface.co/spaces/build-small-hackathon/tiny-quest-forge/resolve/main/app.py
28.3 kB
| 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: | |
| 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() | |
| 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() | |