| """Micro RPG Engine β Gradio app. |
| |
| A text RPG generated live by a small (1B-4B) language model. The model imagines; |
| Python keeps the books. See README.md for the architecture. |
| |
| Run: python app.py |
| Test: MICRORPG_BACKEND=mock python app.py (no weights, no network) |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| import gradio as gr |
|
|
| from engine import GameEngine, GameState, build_backend |
| from engine.parser import TurnResult |
|
|
|
|
| BACKEND_KIND = os.environ.get("MICRORPG_BACKEND", "transformers") |
| MODEL_ID = os.environ.get("MICRORPG_MODEL", "Qwen/Qwen3-4B-Instruct-2507") |
|
|
| |
| _backend = build_backend(BACKEND_KIND, MODEL_ID) |
|
|
|
|
| |
| |
| |
| def render_story(history: list[TurnResult]) -> str: |
| """Render the running story as markdown.""" |
| if not history: |
| return "_Press **Begin Adventure** to enter the world..._" |
| parts = [] |
| for i, turn in enumerate(history): |
| if turn.narrative: |
| parts.append(turn.narrative) |
| if turn.applied: |
| parts.append("> " + " Β· ".join(turn.applied)) |
| return "\n\n".join(parts) |
|
|
|
|
| def render_stats(state: GameState) -> str: |
| hp_pct = int(100 * state.hp / max(1, state.max_hp)) |
| bar = ( |
| f'<div class="hpbar"><div class="hpfill" style="width:{hp_pct}%"></div></div>' |
| ) |
| lines = [ |
| "### βοΈ Hero", |
| f"**HP** {state.hp}/{state.max_hp}", |
| bar, |
| f"**Level** {state.level} (XP {state.xp}/{state.level*10})", |
| f"**Gold** {state.gold} πͺ", |
| f"**Location** {state.location}", |
| "", |
| "**Inventory**", |
| ] |
| lines += [f"- {it}" for it in state.inventory] or ["- (empty)"] |
| if state.enemy and state.enemy.alive: |
| e = state.enemy |
| lines += ["", f"### π‘οΈ Combat", f"**{e.name}** β {e.hp}/{e.max_hp} HP"] |
| if state.npcs: |
| lines += ["", "**Known characters**"] |
| lines += [f"- {n.name} ({n.role or n.disposition})" for n in state.npcs.values()] |
| if state.game_over: |
| lines += ["", "### π **GAME OVER**"] |
| return "\n".join(lines) |
|
|
|
|
| def choice_updates(choices: list[str]): |
| """Map up to 3 model-proposed choices onto the three choice buttons.""" |
| updates = [] |
| for i in range(3): |
| if i < len(choices): |
| updates.append(gr.update(value=choices[i], visible=True)) |
| else: |
| updates.append(gr.update(visible=False)) |
| return updates |
|
|
|
|
| |
| |
| |
| def new_game(): |
| engine = GameEngine(_backend) |
| turn = engine.start() |
| return ( |
| engine, |
| render_story(engine.history), |
| render_stats(engine.state), |
| *choice_updates(turn.choices), |
| "", |
| ) |
|
|
|
|
| def take_action(engine: GameEngine, action: str): |
| if engine is None: |
| engine = GameEngine(_backend) |
| engine.start() |
| if action and action.strip(): |
| turn = engine.act(action) |
| else: |
| turn = engine.history[-1] if engine.history else engine.start() |
| return ( |
| engine, |
| render_story(engine.history), |
| render_stats(engine.state), |
| *choice_updates(turn.choices), |
| "", |
| ) |
|
|
|
|
| |
| |
| |
| def build_ui(): |
| css_path = os.path.join(os.path.dirname(__file__), "style.css") |
| css = open(css_path, encoding="utf-8").read() if os.path.exists(css_path) else "" |
|
|
| with gr.Blocks(css=css, title="Micro RPG Engine", theme=gr.themes.Base()) as demo: |
| engine_state = gr.State(None) |
|
|
| gr.Markdown( |
| f"# π Micro RPG Engine\n" |
| f"*A world dreamed up live by a small model β `{MODEL_ID}` " |
| f"({BACKEND_KIND}). No AI, no game.*", |
| elem_id="title-md", |
| ) |
|
|
| with gr.Row(): |
| with gr.Column(scale=3): |
| story = gr.Markdown(render_story([]), elem_id="story") |
| with gr.Column(scale=1): |
| stats = gr.Markdown("", elem_id="stats") |
|
|
| with gr.Row(): |
| c1 = gr.Button("...", visible=False, variant="secondary") |
| c2 = gr.Button("...", visible=False, variant="secondary") |
| c3 = gr.Button("...", visible=False, variant="secondary") |
|
|
| with gr.Row(): |
| action = gr.Textbox( |
| placeholder="...or type your own action and press Enter", |
| show_label=False, |
| elem_id="action-input", |
| scale=4, |
| ) |
| send = gr.Button("Act", variant="primary", scale=1) |
|
|
| with gr.Row(): |
| begin = gr.Button("π² Begin / Restart Adventure", variant="primary") |
|
|
| outputs = [engine_state, story, stats, c1, c2, c3, action] |
|
|
| begin.click(new_game, outputs=outputs) |
| send.click(take_action, inputs=[engine_state, action], outputs=outputs) |
| action.submit(take_action, inputs=[engine_state, action], outputs=outputs) |
|
|
| |
| for btn in (c1, c2, c3): |
| btn.click(take_action, inputs=[engine_state, btn], outputs=outputs) |
|
|
| return demo |
|
|
|
|
| if __name__ == "__main__": |
| build_ui().launch() |
|
|