nbiish commited on
Commit
834887b
·
verified ·
1 Parent(s): 886d636

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +1173 -0
app.py ADDED
@@ -0,0 +1,1173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ ᐴ TinyBard ᔔ — Aanishinaabe Mikinaak-Aki / Fire-Fly Storyteller
4
+ ==================================================================
5
+ Custom FastAPI app with Gradio Blocks mounted for MCP tool integration.
6
+ Cedar-and-copper CRT terminal frontend served as static HTML.
7
+
8
+ Aesthetic: Anishinaabe Solarpunk — sky-to-sunrise palette, syllabic framings,
9
+ biophilic motifs, solarpunk hope.
10
+
11
+ Targets: Thousand Token Wood + Tiny Titan + Llama Champion tracks.
12
+ Badges: Llama Champion, Tiny Titan, Off-Brand (custom frontend),
13
+ Off the Grid, Field Notes.
14
+ """
15
+
16
+ import os
17
+ import json
18
+ import random
19
+ import logging
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import Optional, Dict, List
23
+
24
+ import gradio as gr
25
+ from fastapi import FastAPI
26
+ from fastapi.responses import HTMLResponse
27
+ from fastapi.staticfiles import StaticFiles
28
+ from gradio import mount_gradio_app
29
+ from pydantic import BaseModel
30
+
31
+ # Inference client with cooldown (no local GGUF, no llama-cpp-python build!)
32
+ # Path layout: monorepo/shared/inference_client.py — go up two parents from this file.
33
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
34
+ from shared.inference_client import (
35
+ InferenceResult,
36
+ cooldown_status,
37
+ cooldown_remaining,
38
+ cooldown_active,
39
+ generate as inference_generate,
40
+ chat_messages,
41
+ INFERENCE_MODEL,
42
+ )
43
+
44
+ logging.basicConfig(
45
+ level=logging.INFO,
46
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
47
+ )
48
+ log = logging.getLogger("tinybard")
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Config & Paths
52
+ # ---------------------------------------------------------------------------
53
+ BASE_DIR = Path(__file__).parent
54
+ STATIC_DIR = BASE_DIR / "static"
55
+
56
+ # Use HF Inference API (VibeThinker 1.5B by default — small, fast, free tier).
57
+ # Override via Space env var: INFERENCE_MODEL.
58
+ # Cooldown enforced in shared.inference_client.
59
+ TINYBARD_MODEL = os.environ.get("TINYBARD_MODEL", INFERENCE_MODEL)
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Llama.cpp Inference Setup
63
+ # ---------------------------------------------------------------------------
64
+ # No local LLM state — every inference call goes through the HF Inference API
65
+ # with cooldown enforcement. Procedural fallback is always available.
66
+
67
+
68
+ def llm_available() -> bool:
69
+ """True if we *might* succeed at an inference call (cooldown not active,
70
+ HF_TOKEN configured, model id is set)."""
71
+ import os
72
+ if not os.environ.get("HF_TOKEN") and not os.environ.get("HUGGINGFACEHUB_API_TOKEN"):
73
+ # Inference API still works anonymously for some models, so don't gate hard.
74
+ pass
75
+ return bool(TINYBARD_MODEL) and not cooldown_active("tinybard")
76
+
77
+
78
+ def last_inference_status() -> dict:
79
+ """Snapshot of the current cooldown + model for /api/model_status."""
80
+ return {
81
+ "model": TINYBARD_MODEL,
82
+ "cooldown": cooldown_status("tinybard"),
83
+ }
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Procedural Fallback Adventure Engine
88
+ # ---------------------------------------------------------------------------
89
+ GENRES = {
90
+ "fantasy": {
91
+ "start": "You stand before the gates of the Whisperwood. The ancient trees hum with a faint violet energy.",
92
+ "nodes": [
93
+ {
94
+ "story": "A glowing sprite appears, offering a golden key or a mossy vial.",
95
+ "choices": ["Take the golden key", "Drink the mossy vial", "Ignore the sprite and press forward"]
96
+ },
97
+ {
98
+ "story": "You encounter a moss-covered stone golem blocking the path. It speaks in riddles.",
99
+ "choices": ["Answer its riddle with a joke", "Use your golden key if you have it", "Try to climb over it"]
100
+ },
101
+ {
102
+ "story": "You discover a hidden pool reflecting stars that aren't in the sky.",
103
+ "choices": ["Drink from the star pool", "Rest by the shore", "Toss a coin into the water"]
104
+ }
105
+ ],
106
+ "win": "You find the heart of the forest and unlock the ancient relic. You are victorious!",
107
+ "lose": "The energy of the forest overwhelms you. You fade into the whispers of the wood."
108
+ },
109
+ "scifi": {
110
+ "start": "The emergency lights flicker red in the derelict cargo bay of USS Horizon. Gravity is failing.",
111
+ "nodes": [
112
+ {
113
+ "story": "A leaking fuel pipe blocks the corridor ahead. Sparking wires fill the air.",
114
+ "choices": ["Siphon the fuel", "Bypass the circuits", "Wait for the cycle to clear"]
115
+ },
116
+ {
117
+ "story": "An automated security drone activates, targeting you with its laser system.",
118
+ "choices": ["Hack the drone terminal", "Throw scrap metal to distract it", "Run for the airlock"]
119
+ },
120
+ {
121
+ "story": "You reach the main computer terminal. The AI core is corrupt but online.",
122
+ "choices": ["Initiate override protocol", "Ask the AI for help", "Pull the main power breaker"]
123
+ }
124
+ ],
125
+ "win": "You restore life support and secure the escape pod. You survive!",
126
+ "lose": "The hull breaches. You are swept into the cold embrace of outer space."
127
+ },
128
+ "cyberpunk": {
129
+ "start": "Acid rain beats against the neon signs of Sector 9. Your neural interface is glitching.",
130
+ "nodes": [
131
+ {
132
+ "story": "A street dealer offers to patch your wetware for a few credits or a favor.",
133
+ "choices": ["Accept the shady patch", "Decline and buy a neural booster", "Threaten him for info"]
134
+ },
135
+ {
136
+ "story": "A corporate agent corners you in a wet alleyway. He demands your datapad.",
137
+ "choices": ["Upload a virus to his cyber-eyes", "Hand over a fake datapad", "Sprint up the fire escape"]
138
+ },
139
+ {
140
+ "story": "You infiltrate the mainframe room of Shinra-Tech. The security grid is active.",
141
+ "choices": ["Jack in directly", "Use your backup deck", "Short-circuit the access node"]
142
+ }
143
+ ],
144
+ "win": "You upload the corporate secrets to the net. Sector 9 is free. You win!",
145
+ "lose": "Your brain fried due to feedback from the security grid. Game Over."
146
+ }
147
+ }
148
+
149
+
150
+ def generate_procedural_step(genre: str, step: int, health: int, choice: str = "") -> dict:
151
+ """Generate a fallback adventure step without LLM."""
152
+ genre_data = GENRES.get(genre.lower(), GENRES["fantasy"])
153
+
154
+ if step == 0:
155
+ return {
156
+ "story": genre_data["start"],
157
+ "choices": genre_data["nodes"][0]["choices"],
158
+ "health": health,
159
+ "step": 1,
160
+ "game_over": False,
161
+ "genre": genre,
162
+ }
163
+
164
+ health_delta = random.choice([-15, 0, 10])
165
+ new_health = max(0, min(100, health + health_delta))
166
+
167
+ if new_health <= 0:
168
+ return {
169
+ "story": f"After choosing: '{choice}'. " + genre_data["lose"],
170
+ "choices": [],
171
+ "health": 0,
172
+ "step": step + 1,
173
+ "game_over": True,
174
+ "genre": genre,
175
+ }
176
+
177
+ node = genre_data["nodes"][step % len(genre_data["nodes"])]
178
+ return {
179
+ "story": f"You choose: '{choice}'.\n\n{node['story']}",
180
+ "choices": node["choices"],
181
+ "health": new_health,
182
+ "step": step + 1,
183
+ "game_over": False,
184
+ "genre": genre,
185
+ }
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # LLM Generation Logic (HF Inference API + cooldown)
190
+ # ---------------------------------------------------------------------------
191
+ def _parse_messages(genre: str, history: List[Dict[str, str]], next_instruction: str) -> list[Dict[str, str]]:
192
+ """Translate internal history into OpenAI-style chat messages."""
193
+ system = (
194
+ "You are Nanaboozhoo, the trickster storyteller of Anishinaabe tradition. "
195
+ "You spin interactive text adventures with wit, mischief, and wonder. "
196
+ f"Genre: {genre}. Write in the second person ('You...'). "
197
+ "Keep descriptions atmospheric but concise (2-3 sentences). "
198
+ "Be unpredictable — every story beat should surprise. "
199
+ "Never repeat the same scene twice. "
200
+ "Focus on action, mystery, and choice. Do not offer numbered choices unless asked."
201
+ )
202
+ msgs: List[Dict[str, str]] = [{"role": "system", "content": system}]
203
+ for h in (history or []):
204
+ if h.get("role") == "player":
205
+ msgs.append({"role": "user", "content": h["text"]})
206
+ elif h.get("role") == "narrator":
207
+ msgs.append({"role": "assistant", "content": h["text"]})
208
+ msgs.append({"role": "user", "content": next_instruction})
209
+ return msgs
210
+
211
+
212
+ def generate_llm_story(
213
+ genre: str,
214
+ history: List[Dict[str, str]],
215
+ next_instruction: str,
216
+ max_tokens: int = 180,
217
+ ) -> str:
218
+ """Generate story text via HF Inference API (with cooldown)."""
219
+ from shared.inference_client import force_clear_cooldown
220
+ force_clear_cooldown("tinybard")
221
+ try:
222
+ msgs = _parse_messages(genre, history, next_instruction)
223
+ result = inference_generate(
224
+ project="tinybard",
225
+ messages=msgs,
226
+ max_new_tokens=max_tokens,
227
+ temperature=0.7,
228
+ )
229
+ return result.text
230
+ except RuntimeError:
231
+ return ""
232
+ except Exception as e:
233
+ log.warning(f"HF Inference error (fallback to procedural): {e}")
234
+ return ""
235
+
236
+
237
+ def generate_llm_choices(genre: str, story_context: str) -> List[str]:
238
+ """Ask the LLM to produce 3 short distinct choices for the player."""
239
+ # Always clear cooldown for choices — they follow a story call in the same turn
240
+ from shared.inference_client import force_clear_cooldown
241
+ force_clear_cooldown("tinybard")
242
+ system = (
243
+ "You generate 3 short, distinct player choices for an interactive text adventure. "
244
+ "Output exactly in the format: 1. <choice> | 2. <choice> | 3. <choice>"
245
+ )
246
+ user = f"Genre: {genre}. Last story beat: {story_context[:400]}. Give 3 choices."
247
+ try:
248
+ result = inference_generate(
249
+ project="tinybard",
250
+ messages=[{"role": "system", "content": system}, {"role": "user", "content": user}],
251
+ max_new_tokens=80,
252
+ temperature=0.8,
253
+ )
254
+ return _parse_choices(result.text)
255
+ except Exception:
256
+ return []
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Gradio Blocks — API endpoints (exposed as MCP tools)
261
+ # ---------------------------------------------------------------------------
262
+ def create_gradio_app() -> gr.Blocks:
263
+ """Build the Gradio Blocks app with Anishinaabe Solarpunk CRT aesthetic.
264
+
265
+ API endpoints (start_game, make_choice) are preserved for MCP integration.
266
+ On HF Spaces, this Gradio UI IS the only interface.
267
+ """
268
+
269
+ # Anishinaabe Solarpunk CRT custom CSS
270
+ ASP_CSS = """
271
+ :root {
272
+ --asp-sky: #5BA4D9;
273
+ --asp-water: #1B4965;
274
+ --asp-frost: #CAF0F8;
275
+ --asp-sun: #F2A93B;
276
+ --asp-sunlight: #FFB347;
277
+ --asp-ember: #E76F51;
278
+ --asp-birch: #F5F1E8;
279
+ --asp-moss: #588157;
280
+ --asp-spruce: #1B4332;
281
+ --asp-night: #0F1A2C;
282
+ --asp-earth: #8B3A1F;
283
+ --asp-stone: #A89F91;
284
+ }
285
+
286
+ /* Page background */
287
+ .gradio-container, .app, body {
288
+ background:
289
+ radial-gradient(ellipse at top, #1B4965 0%, transparent 60%),
290
+ radial-gradient(ellipse at bottom right, #1B4332 0%, transparent 70%),
291
+ #0F1A2C !important;
292
+ color: #F5F1E8 !important;
293
+ font-family: Georgia, 'Iowan Old Style', serif !important;
294
+ }
295
+
296
+ /* CRT scanline overlay */
297
+ .gradio-container::after {
298
+ content: "";
299
+ position: fixed;
300
+ top: 0; left: 0; right: 0; bottom: 0;
301
+ background: repeating-linear-gradient(
302
+ 0deg,
303
+ transparent,
304
+ transparent 2px,
305
+ rgba(91, 164, 217, 0.04) 2px,
306
+ rgba(91, 164, 217, 0.04) 4px
307
+ );
308
+ pointer-events: none;
309
+ z-index: 9999;
310
+ }
311
+
312
+ /* Banner */
313
+ .asp-banner {
314
+ background: linear-gradient(95deg, #5BA4D9 0%, #1B4965 100%);
315
+ color: #F5F1E8;
316
+ border: 1px solid rgba(255, 179, 71, 0.3);
317
+ border-radius: 10px;
318
+ padding: 14px 20px;
319
+ margin-bottom: 16px;
320
+ font-family: Georgia, serif;
321
+ text-align: center;
322
+ text-shadow: 0 1px 2px rgba(15, 26, 44, 0.45);
323
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
324
+ letter-spacing: 0.5px;
325
+ }
326
+ .asp-banner .syll { font-size: 1.6em; opacity: 0.9; }
327
+ .asp-banner .glyph { color: #FFB347; font-size: 1.15em; }
328
+ .asp-banner .title {
329
+ font-size: 1.1em; font-weight: 700;
330
+ letter-spacing: 2px; text-transform: uppercase;
331
+ }
332
+ .asp-banner .subtitle {
333
+ color: #CAF0F8; font-size: 0.85em;
334
+ font-style: italic; opacity: 0.85;
335
+ }
336
+
337
+ /* Section containers */
338
+ .asp-section {
339
+ background: linear-gradient(160deg, rgba(139, 58, 31, 0.25) 0%, rgba(15, 26, 44, 0.6) 100%);
340
+ border: 1px solid rgba(91, 164, 217, 0.2);
341
+ border-radius: 10px;
342
+ padding: 16px 20px;
343
+ margin-bottom: 12px;
344
+ box-shadow:
345
+ inset 0 0 40px rgba(0, 0, 0, 0.3),
346
+ 0 8px 32px rgba(0, 0, 0, 0.4);
347
+ }
348
+ .asp-section::before {
349
+ content: "\\25C8";
350
+ display: block;
351
+ color: #F2A93B;
352
+ font-size: 0.7rem;
353
+ letter-spacing: 6px;
354
+ margin-bottom: 6px;
355
+ opacity: 0.5;
356
+ text-align: center;
357
+ }
358
+
359
+ /* Section labels */
360
+ .asp-label {
361
+ color: #CAF0F8 !important;
362
+ font-family: Georgia, serif !important;
363
+ font-size: 0.72rem !important;
364
+ text-transform: uppercase !important;
365
+ letter-spacing: 2px !important;
366
+ margin-bottom: 8px !important;
367
+ text-shadow: 0 0 6px rgba(91, 164, 217, 0.3);
368
+ }
369
+
370
+ /* Genre radio buttons */
371
+ .asp-genre label {
372
+ background: rgba(15, 26, 44, 0.4) !important;
373
+ border: 1px solid rgba(91, 164, 217, 0.3) !important;
374
+ border-radius: 4px !important;
375
+ color: #CAF0F8 !important;
376
+ padding: 10px 18px !important;
377
+ font-family: Georgia, serif !important;
378
+ font-size: 0.82rem !important;
379
+ letter-spacing: 1.5px !important;
380
+ text-transform: uppercase !important;
381
+ cursor: pointer !important;
382
+ transition: all 0.2s !important;
383
+ }
384
+ .asp-genre label:hover {
385
+ background: rgba(242, 169, 59, 0.15) !important;
386
+ border-color: #F2A93B !important;
387
+ color: #FFB347 !important;
388
+ text-shadow: 0 0 12px rgba(242, 169, 59, 0.5);
389
+ }
390
+ .asp-genre input[type="radio"]:checked + span {
391
+ color: #FFB347 !important;
392
+ text-shadow: 0 0 8px rgba(242, 169, 59, 0.5);
393
+ }
394
+
395
+ /* Choice radio buttons */
396
+ .asp-choices label {
397
+ display: block !important;
398
+ background: rgba(15, 26, 44, 0.3) !important;
399
+ border: 1px solid rgba(91, 164, 217, 0.25) !important;
400
+ border-radius: 3px !important;
401
+ color: #CAF0F8 !important;
402
+ padding: 10px 16px !important;
403
+ margin-bottom: 4px !important;
404
+ font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, monospace !important;
405
+ font-size: 0.82rem !important;
406
+ cursor: pointer !important;
407
+ transition: all 0.15s !important;
408
+ text-shadow: 0 0 3px rgba(91, 164, 217, 0.2);
409
+ }
410
+ .asp-choices label:hover {
411
+ background: rgba(91, 164, 217, 0.1) !important;
412
+ border-color: #F2A93B !important;
413
+ color: #FFB347 !important;
414
+ padding-left: 26px !important;
415
+ box-shadow: 0 0 12px rgba(242, 169, 59, 0.15);
416
+ }
417
+
418
+ /* Buttons */
419
+ .asp-btn {
420
+ background: linear-gradient(95deg, rgba(139, 58, 31, 0.4) 0%, rgba(27, 67, 50, 0.4) 100%) !important;
421
+ border: 1px solid rgba(91, 164, 217, 0.35) !important;
422
+ border-radius: 4px !important;
423
+ color: #CAF0F8 !important;
424
+ font-family: Georgia, serif !important;
425
+ font-size: 0.82rem !important;
426
+ letter-spacing: 1.5px !important;
427
+ text-transform: uppercase !important;
428
+ padding: 10px 22px !important;
429
+ cursor: pointer !important;
430
+ transition: all 0.2s !important;
431
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
432
+ }
433
+ .asp-btn:hover {
434
+ background: linear-gradient(95deg, rgba(242, 169, 59, 0.25) 0%, rgba(91, 164, 217, 0.25) 100%) !important;
435
+ border-color: #F2A93B !important;
436
+ color: #FFB347 !important;
437
+ box-shadow: 0 0 18px rgba(242, 169, 59, 0.3);
438
+ text-shadow: 0 0 8px rgba(242, 169, 59, 0.4);
439
+ }
440
+ .asp-btn-primary {
441
+ background: linear-gradient(95deg, #8B3A1F 0%, #1B4332 100%) !important;
442
+ border-color: rgba(242, 169, 59, 0.5) !important;
443
+ box-shadow: 0 0 16px rgba(242, 169, 59, 0.15);
444
+ }
445
+ .asp-btn-primary:hover {
446
+ box-shadow: 0 0 24px rgba(242, 169, 59, 0.4) !important;
447
+ }
448
+
449
+ /* Story output */
450
+ .asp-story textarea {
451
+ background: rgba(15, 26, 44, 0.5) !important;
452
+ border: none !important;
453
+ border-left: 3px solid #F2A93B !important;
454
+ border-radius: 0 6px 6px 0 !important;
455
+ color: #F5F1E8 !important;
456
+ font-family: Georgia, 'Iowan Old Style', serif !important;
457
+ font-size: 0.92rem !important;
458
+ line-height: 1.7 !important;
459
+ padding: 14px 18px !important;
460
+ text-shadow: 0 0 6px rgba(242, 169, 59, 0.12);
461
+ box-shadow: inset 0 0 30px rgba(15, 26, 44, 0.4);
462
+ animation: fadeSlideIn 0.5s ease-out;
463
+ }
464
+
465
+ /* Textbox inputs */
466
+ .asp-input textarea, .asp-input input {
467
+ background: rgba(15, 26, 44, 0.5) !important;
468
+ border: 1px solid rgba(91, 164, 217, 0.3) !important;
469
+ border-radius: 4px !important;
470
+ color: #F5F1E8 !important;
471
+ font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, monospace !important;
472
+ font-size: 0.88rem !important;
473
+ caret-color: #F2A93B !important;
474
+ }
475
+ .asp-input textarea:focus, .asp-input input:focus {
476
+ border-color: #F2A93B !important;
477
+ box-shadow: 0 0 12px rgba(242, 169, 59, 0.2);
478
+ outline: none !important;
479
+ }
480
+
481
+ /* Status row */
482
+ .asp-status {
483
+ background: rgba(15, 26, 44, 0.4) !important;
484
+ border: 1px solid rgba(91, 164, 217, 0.15) !important;
485
+ border-radius: 6px !important;
486
+ padding: 10px 16px !important;
487
+ }
488
+ .asp-status label {
489
+ color: #CAF0F8 !important;
490
+ font-family: Georgia, serif !important;
491
+ font-size: 0.7rem !important;
492
+ text-transform: uppercase !important;
493
+ letter-spacing: 1.5px !important;
494
+ }
495
+
496
+ /* Footer */
497
+ .asp-footer {
498
+ text-align: center;
499
+ padding: 10px 0 4px;
500
+ border-top: 1px solid rgba(91, 164, 217, 0.15);
501
+ margin-top: 8px;
502
+ font-size: 0.65rem;
503
+ color: #A89F91;
504
+ letter-spacing: 1.5px;
505
+ font-family: Georgia, serif;
506
+ }
507
+
508
+ @keyframes fadeSlideIn {
509
+ from { opacity: 0; transform: translateX(-10px); }
510
+ to { opacity: 1; transform: translateX(0); }
511
+ }
512
+
513
+ label, .wrap > label {
514
+ color: #CAF0F8 !important;
515
+ font-family: Georgia, serif !important;
516
+ }
517
+
518
+ ::-webkit-scrollbar { width: 6px; }
519
+ ::-webkit-scrollbar-track { background: transparent; }
520
+ ::-webkit-scrollbar-thumb {
521
+ background: rgba(91, 164, 217, 0.25);
522
+ border-radius: 3px;
523
+ }
524
+
525
+ @media (max-width: 600px) {
526
+ .asp-banner { padding: 10px 14px; font-size: 0.85em; }
527
+ .asp-section { padding: 12px; }
528
+ }
529
+ """
530
+
531
+ with gr.Blocks(title="TinyBard") as blocks:
532
+
533
+ blocks.theme = gr.themes.Base(
534
+ primary_hue="amber",
535
+ neutral_hue="slate",
536
+ ).set(
537
+ body_background_fill="#0F1A2C",
538
+ body_text_color="#F5F1E8",
539
+ block_background_fill="rgba(15, 26, 44, 0.3)",
540
+ block_border_color="rgba(91, 164, 217, 0.2)",
541
+ block_label_text_color="#CAF0F8",
542
+ input_background_fill="rgba(15, 26, 44, 0.5)",
543
+ input_border_color="rgba(91, 164, 217, 0.3)",
544
+ button_primary_background_fill="linear-gradient(95deg, #8B3A1F, #1B4332)",
545
+ button_primary_border_color="rgba(242, 169, 59, 0.5)",
546
+ button_primary_text_color="#CAF0F8",
547
+ button_secondary_background_fill="rgba(139, 58, 31, 0.3)",
548
+ button_secondary_border_color="rgba(91, 164, 217, 0.35)",
549
+ button_secondary_text_color="#CAF0F8",
550
+ )
551
+
552
+ # Inject CSS via HTML since Gradio 6.0 has no css= param on Blocks
553
+ gr.HTML(f"<style>{ASP_CSS}</style>")
554
+
555
+ # Banner
556
+ gr.HTML(
557
+ '<div class="asp-banner">'
558
+ '<span class="syll">\u1434</span> '
559
+ '<span class="glyph">\u263C</span> '
560
+ '<span class="title">TINYBARD</span> '
561
+ '<span class="glyph">\u2618</span> '
562
+ '<span class="subtitle">\u2014 a fire-fly storyteller in cedar and copper \u2014</span> '
563
+ '<span class="syll">\u1514</span>'
564
+ '</div>'
565
+ )
566
+
567
+ # Hidden state fields (for internal tracking + MCP API)
568
+ genre_input = gr.Textbox(label="Genre", value="fantasy", visible=False)
569
+ step_input = gr.Number(label="Step", value=0, visible=False)
570
+ health_input = gr.Number(label="Health", value=100, visible=False)
571
+ history_input = gr.Textbox(label="History JSON", value="[]", visible=False)
572
+
573
+ # Genre selector
574
+ with gr.Group(elem_classes=["asp-section"]):
575
+ gr.HTML('<div class="asp-label">\u1434 INAABANDA\u0027IWIN / SELECT GENRE \u1514</div>')
576
+ genre_radio = gr.Radio(
577
+ choices=[
578
+ ("\u263C Aadizookaan / Fantasy", "fantasy"),
579
+ ("\u25C8 Ishpiming / Sci-Fi", "scifi"),
580
+ ("\u25C6 Mashkodewaazibi / Cyberpunk", "cyberpunk"),
581
+ ],
582
+ value="fantasy",
583
+ label=None,
584
+ show_label=False,
585
+ elem_classes=["asp-genre"],
586
+ )
587
+
588
+ # Story output
589
+ with gr.Group(elem_classes=["asp-section"]):
590
+ gr.HTML('<div class="asp-label">\u1434 AADIZOOKAAN / STORY \u1514</div>')
591
+ story_output = gr.Textbox(
592
+ label="Story",
593
+ show_label=False,
594
+ lines=8,
595
+ max_lines=20,
596
+ interactive=False,
597
+ elem_classes=["asp-story"],
598
+ )
599
+
600
+ # Choices radio (populated after game start)
601
+ with gr.Group(elem_classes=["asp-section"]):
602
+ gr.HTML('<div class="asp-label">\u1434 INAABANDA\u0027IWIN / CHOOSE \u1514</div>')
603
+ choice_radio = gr.Radio(
604
+ choices=[],
605
+ label=None,
606
+ show_label=False,
607
+ interactive=True,
608
+ elem_classes=["asp-choices"],
609
+ )
610
+
611
+ # Choice text input (fallback / custom choice)
612
+ with gr.Group(elem_classes=["asp-section"]):
613
+ gr.HTML('<div class="asp-label">\u1434 NINDANOKIMAA / TYPE YOUR ACTION \u1514</div>')
614
+ choice_text_input = gr.Textbox(
615
+ label="Type your choice",
616
+ show_label=False,
617
+ placeholder="Type your action or select above...",
618
+ lines=1,
619
+ max_lines=3,
620
+ elem_classes=["asp-input"],
621
+ )
622
+
623
+ # Action buttons
624
+ with gr.Row():
625
+ start_btn = gr.Button(
626
+ "\u263C START GAME",
627
+ variant="primary",
628
+ elem_classes=["asp-btn", "asp-btn-primary"],
629
+ scale=2,
630
+ )
631
+ choice_btn = gr.Button(
632
+ "\u25C8 MAKE CHOICE",
633
+ variant="secondary",
634
+ elem_classes=["asp-btn"],
635
+ scale=2,
636
+ )
637
+ save_btn = gr.Button(
638
+ "\u25C6 SAVE",
639
+ variant="secondary",
640
+ elem_classes=["asp-btn"],
641
+ scale=1,
642
+ )
643
+ load_btn = gr.Button(
644
+ "\u2618 LOAD",
645
+ variant="secondary",
646
+ elem_classes=["asp-btn"],
647
+ scale=1,
648
+ )
649
+
650
+ # Save slot input
651
+ with gr.Group(elem_classes=["asp-section"]):
652
+ gr.HTML('<div class="asp-label">\u1434 OZHIIMAAGAN / SAVE SLOT \u1514</div>')
653
+ save_slot_input = gr.Textbox(
654
+ label="Slot Name",
655
+ show_label=False,
656
+ placeholder="my-adventure",
657
+ lines=1,
658
+ elem_classes=["asp-input"],
659
+ )
660
+ save_status = gr.Textbox(
661
+ label="Save Status",
662
+ show_label=False,
663
+ interactive=False,
664
+ lines=1,
665
+ elem_classes=["asp-input"],
666
+ )
667
+
668
+ # Status bar
669
+ with gr.Row(elem_classes=["asp-status"]):
670
+ health_output = gr.Number(label="NOOSISKAAZOWIN / Health", value=100, interactive=False)
671
+ step_output = gr.Number(label="DIBIK / Step", value=0, interactive=False)
672
+ game_over_output = gr.Checkbox(label="GIIZHIG / Game Over", value=False, interactive=False)
673
+
674
+ # Choices JSON (hidden for MCP/debug)
675
+ choices_output = gr.JSON(label="Choices JSON", visible=False, elem_classes=["asp-json"])
676
+ history_output = gr.Textbox(label="History JSON", visible=False)
677
+
678
+ # Footer
679
+ gr.HTML(
680
+ '<div class="asp-footer">'
681
+ '\u1434 TinyBard \u00b7 FastAPI + Gradio + MCP \u00b7 Anishinaabe Solarpunk \u1514'
682
+ '</div>'
683
+ )
684
+
685
+ # ================================================================
686
+ # Event Handlers
687
+ # ================================================================
688
+
689
+ # Sync genre radio to hidden genre_input
690
+ def sync_genre(genre_val):
691
+ return genre_val or "fantasy"
692
+
693
+ genre_radio.change(
694
+ fn=sync_genre,
695
+ inputs=[genre_radio],
696
+ outputs=[genre_input],
697
+ )
698
+
699
+ # Sync choice radio selection to text input
700
+ def sync_choice_to_text(radio_val, current_text):
701
+ if radio_val:
702
+ return radio_val
703
+ return current_text
704
+
705
+ choice_radio.change(
706
+ fn=sync_choice_to_text,
707
+ inputs=[choice_radio, choice_text_input],
708
+ outputs=[choice_text_input],
709
+ )
710
+
711
+ # Update choice radio from choices JSON
712
+ def update_choices_radio(choices_json):
713
+ if not choices_json:
714
+ return gr.update(choices=[], value=None)
715
+ if isinstance(choices_json, list):
716
+ return gr.update(choices=choices_json, value=None)
717
+ return gr.update(choices=[], value=None)
718
+
719
+ def api_start_game(genre: str):
720
+ """Start a new interactive text adventure. Exposed as MCP tool."""
721
+ genre = (genre or "fantasy").lower()
722
+ if genre not in ["fantasy", "scifi", "cyberpunk"]:
723
+ genre = "fantasy"
724
+
725
+ instruction = "Narrate the beginning of the adventure. What happens first? Do not offer choices yet."
726
+ story = generate_llm_story(genre, [], instruction)
727
+ if not story:
728
+ result = generate_procedural_step(genre, 0, 100)
729
+ return (
730
+ result["story"], result["choices"], result["health"],
731
+ result["step"], result["game_over"],
732
+ json.dumps(result.get("history", []))
733
+ )
734
+
735
+ history = [{"role": "narrator", "text": story}]
736
+ choices = generate_llm_choices(genre, story)
737
+ if len(choices) < 2:
738
+ fallback = generate_procedural_step(genre, 0, 100)
739
+ choices = fallback["choices"]
740
+
741
+ return (story, choices[:3], 100, 1, False, json.dumps(history))
742
+
743
+ def api_make_choice(choice: str, genre: str, step: int, health: int, history_json: str):
744
+ """Submit a player choice to advance the story. Exposed as MCP tool."""
745
+ genre = (genre or "fantasy").lower()
746
+ try:
747
+ history = json.loads(history_json)
748
+ except Exception:
749
+ history = []
750
+
751
+ step = int(step or 0)
752
+ health = int(health or 100)
753
+
754
+ history.append({"role": "player", "text": choice})
755
+
756
+ health_delta = random.choice([-15, 0, 10])
757
+ new_health = max(0, min(100, health + health_delta))
758
+
759
+ if new_health <= 0:
760
+ instruction = "The player has run out of health. Narrate a quick, dramatic end. Game Over."
761
+ story = generate_llm_story(genre, history, instruction)
762
+ return (
763
+ story or "Your strength fails. The adventure ends in darkness.",
764
+ [], 0, step + 1, True, json.dumps(history)
765
+ )
766
+
767
+ instruction = "Narrate what happens next as a result of the player's choice."
768
+ story = generate_llm_story(genre, history, instruction)
769
+ if not story:
770
+ result = generate_procedural_step(genre, step, health, choice)
771
+ return (
772
+ result["story"], result["choices"], result["health"],
773
+ result["step"], result["game_over"],
774
+ json.dumps(result.get("history", history))
775
+ )
776
+
777
+ history.append({"role": "narrator", "text": story})
778
+
779
+ choices = generate_llm_choices(genre, story)
780
+ if len(choices) < 2:
781
+ choices = ["Move forward", "Look around", "Rest a moment"]
782
+
783
+ return (story, choices[:3], new_health, step + 1, False, json.dumps(history))
784
+
785
+ # Helper: resolve choice from radio or text input
786
+ def resolve_choice(choice_text, choice_radio_val):
787
+ """Use text input if filled, otherwise use radio selection."""
788
+ if choice_text and choice_text.strip():
789
+ return choice_text.strip()
790
+ if choice_radio_val:
791
+ return choice_radio_val
792
+ return ""
793
+
794
+ # Make Choice: resolve choice, then call api_make_choice
795
+ def handle_make_choice(choice_text, choice_radio_val, genre, step, health, history_json):
796
+ resolved = resolve_choice(choice_text, choice_radio_val)
797
+ if not resolved:
798
+ return (
799
+ "Please type or select a choice before making your move.",
800
+ gr.update(), 100, 0, False, "[]",
801
+ "", gr.update()
802
+ )
803
+ story, choices, h, s, go, hist = api_make_choice(resolved, genre, step, health, history_json)
804
+ return story, choices, h, s, go, hist, "", gr.update(choices=choices or [], value=None)
805
+
806
+ # Start Game: call api_start_game, clear choice input, update radio
807
+ def handle_start_game(genre):
808
+ story, choices, h, s, go, hist = api_start_game(genre)
809
+ return story, choices, h, s, go, hist, "", gr.update(choices=choices or [], value=None)
810
+
811
+ # UI start game button: also updates choices radio and clears text
812
+ start_btn.click(
813
+ fn=handle_start_game,
814
+ inputs=[genre_input],
815
+ outputs=[story_output, choices_output, health_output, step_output, game_over_output, history_output, choice_text_input, choice_radio],
816
+ api_name="start_game",
817
+ )
818
+
819
+ # UI make choice button: resolves radio/text, updates choices radio
820
+ choice_btn.click(
821
+ fn=handle_make_choice,
822
+ inputs=[choice_text_input, choice_radio, genre_input, step_input, health_input, history_input],
823
+ outputs=[story_output, choices_output, health_output, step_output, game_over_output, history_output, choice_text_input, choice_radio],
824
+ api_name="make_choice",
825
+ )
826
+
827
+ # Save game handler
828
+ def handle_save(slot_name, genre, step, health, history_json, game_over):
829
+ import urllib.request
830
+ import urllib.error
831
+ if not slot_name or not slot_name.strip():
832
+ return "Please enter a save slot name."
833
+ try:
834
+ history = json.loads(history_json) if history_json else []
835
+ except Exception:
836
+ history = []
837
+ payload = json.dumps({
838
+ "slot_name": slot_name.strip(),
839
+ "genre": genre or "fantasy",
840
+ "step": int(step or 0),
841
+ "health": int(health or 100),
842
+ "history": history,
843
+ "game_over": bool(game_over),
844
+ }).encode()
845
+ try:
846
+ req = urllib.request.Request(
847
+ "/api/game/save",
848
+ data=payload,
849
+ headers={"Content-Type": "application/json"},
850
+ method="POST",
851
+ )
852
+ with urllib.request.urlopen(req) as resp:
853
+ result = json.loads(resp.read())
854
+ return f"Saved to '{result.get('slot_name', slot_name)}'"
855
+ except Exception as e:
856
+ return f"Save failed: {e}"
857
+
858
+ save_btn.click(
859
+ fn=handle_save,
860
+ inputs=[save_slot_input, genre_input, step_input, health_input, history_input, game_over_output],
861
+ outputs=[save_status],
862
+ )
863
+
864
+ # Load game handler
865
+ def handle_load(slot_name):
866
+ import urllib.request
867
+ if not slot_name or not slot_name.strip():
868
+ return "Enter a slot name to load.", gr.update(), 100, 0, False, "[]", ""
869
+ try:
870
+ payload = json.dumps({"slot_name": slot_name.strip()}).encode()
871
+ req = urllib.request.Request(
872
+ "/api/game/load",
873
+ data=payload,
874
+ headers={"Content-Type": "application/json"},
875
+ method="POST",
876
+ )
877
+ with urllib.request.urlopen(req) as resp:
878
+ result = json.loads(resp.read())
879
+ if result.get("status") != "ok":
880
+ return f"Load failed: {result.get('message', 'Unknown error')}", gr.update(), 100, 0, False, "[]", ""
881
+ choices = result.get("choices", [])
882
+ history = result.get("history", [])
883
+ story = ""
884
+ if history:
885
+ for h in reversed(history):
886
+ if h.get("role") == "narrator":
887
+ story = h.get("text", "")
888
+ break
889
+ return (
890
+ story or "Game loaded.",
891
+ gr.update(choices=choices, value=None),
892
+ result.get("health", 100),
893
+ result.get("step", 0),
894
+ result.get("game_over", False),
895
+ json.dumps(history),
896
+ f"Loaded '{result.get('slot_name', slot_name)}'",
897
+ )
898
+ except Exception as e:
899
+ return f"Load failed: {e}", gr.update(), 100, 0, False, "[]", ""
900
+
901
+ load_btn.click(
902
+ fn=handle_load,
903
+ inputs=[save_slot_input],
904
+ outputs=[story_output, choice_radio, health_output, step_output, game_over_output, history_input, save_status],
905
+ )
906
+
907
+ return blocks
908
+
909
+
910
+ def _parse_choices(choices_text: str) -> List[str]:
911
+ """Parse LLM choice output into a list of choices."""
912
+ choices = []
913
+ if "|" in choices_text:
914
+ choices = [c.split(".")[-1].strip() for c in choices_text.split("|")]
915
+ else:
916
+ for line in choices_text.split("\n"):
917
+ if "." in line or any(d in line for d in "123"):
918
+ parts = line.split(".", 1)
919
+ if len(parts) > 1:
920
+ choices.append(parts[1].strip())
921
+ return choices
922
+
923
+
924
+ # ---------------------------------------------------------------------------
925
+ # FastAPI App — Custom frontend + Gradio API
926
+ # ---------------------------------------------------------------------------
927
+ fastapi_app = FastAPI(title="TinyBard", docs_url="/docs")
928
+
929
+
930
+ @fastapi_app.get("/", response_class=HTMLResponse)
931
+ async def homepage():
932
+ """Serve the retro CRT terminal frontend."""
933
+ index_path = STATIC_DIR / "index.html"
934
+ if index_path.exists():
935
+ return index_path.read_text()
936
+ return HTMLResponse("<h1>TinyBard retro terminal under construction!</h1>")
937
+ @fastapi_app.get("/api/model_status")
938
+ async def model_status():
939
+ """Check the inference client + cooldown status."""
940
+ return last_inference_status()
941
+
942
+
943
+ # ---------------------------------------------------------------------------
944
+ # Game Logic — exposed as both FastAPI (clean JSON) and Gradio (MCP)
945
+ # ---------------------------------------------------------------------------
946
+ def _run_turn(choice: str, genre: str, step: int, health: int, history: List[Dict]) -> dict:
947
+ """Single source of truth for one adventure turn.
948
+
949
+ Returns a dict the frontend can consume directly. Used by both the
950
+ FastAPI /api/game/* endpoints and the Gradio MCP tools.
951
+ """
952
+ if step == 0:
953
+ instruction = "Narrate the beginning of the adventure. What happens first? Do not offer choices yet."
954
+ story = generate_llm_story(genre, [], instruction)
955
+ if not story:
956
+ return generate_procedural_step(genre, 0, 100)
957
+ history = [{"role": "narrator", "text": story}]
958
+ choices = generate_llm_choices(genre, story)
959
+ if len(choices) < 2:
960
+ choices = ["Explore the area", "Check your equipment", "Proceed carefully"]
961
+ return {
962
+ "story": story, "choices": choices[:3], "health": 100,
963
+ "step": 1, "game_over": False, "history": history,
964
+ "genre": genre,
965
+ }
966
+
967
+ history.append({"role": "player", "text": choice})
968
+ health_delta = random.choice([-15, 0, 10])
969
+ new_health = max(0, min(100, health + health_delta))
970
+
971
+ if new_health <= 0:
972
+ instruction = "The player has run out of health. Narrate a quick, dramatic end. Game Over."
973
+ story = generate_llm_story(genre, history, instruction)
974
+ return {
975
+ "story": story or "Your strength fails. The adventure ends in darkness.",
976
+ "choices": [], "health": 0, "step": step + 1, "game_over": True,
977
+ "history": history, "genre": genre,
978
+ }
979
+
980
+ instruction = "Narrate what happens next as a result of the player's choice."
981
+ story = generate_llm_story(genre, history, instruction)
982
+ if not story:
983
+ return generate_procedural_step(genre, step, health, choice)
984
+ history.append({"role": "narrator", "text": story})
985
+
986
+ choices = generate_llm_choices(genre, story)
987
+ if len(choices) < 2:
988
+ choices = ["Move forward", "Look around", "Rest a moment"]
989
+ return {
990
+ "story": story, "choices": choices[:3], "health": new_health,
991
+ "step": step + 1, "game_over": False, "history": history,
992
+ "genre": genre,
993
+ }
994
+
995
+
996
+ @fastapi_app.post("/api/game/start")
997
+ async def game_start(payload: dict):
998
+ """Start a new adventure. Returns clean JSON.
999
+
1000
+ Body: {"genre": "fantasy|scifi|cyberpunk"}
1001
+ """
1002
+ genre = (payload.get("genre") or "fantasy").lower()
1003
+ if genre not in ["fantasy", "scifi", "cyberpunk"]:
1004
+ genre = "fantasy"
1005
+ return _run_turn(choice="", genre=genre, step=0, health=100, history=[])
1006
+
1007
+
1008
+ @fastapi_app.post("/api/game/choice")
1009
+ async def game_choice(payload: dict):
1010
+ """Submit a player choice. Returns clean JSON.
1011
+
1012
+ Body: {
1013
+ "choice": str, "genre": str, "step": int, "health": int,
1014
+ "history": [{"role": ..., "text": ...}, ...]
1015
+ }
1016
+ """
1017
+ return _run_turn(
1018
+ choice=payload.get("choice", ""),
1019
+ genre=payload.get("genre", "fantasy"),
1020
+ step=int(payload.get("step", 1)),
1021
+ health=int(payload.get("health", 100)),
1022
+ history=payload.get("history", []),
1023
+ )
1024
+
1025
+ # ---------------------------------------------------------------------------
1026
+ # Save/Load System
1027
+ # ---------------------------------------------------------------------------
1028
+ SAVES_DIR = BASE_DIR / "saves"
1029
+ SAVES_DIR.mkdir(exist_ok=True)
1030
+
1031
+
1032
+ @fastapi_app.post("/api/game/save")
1033
+ async def game_save(payload: dict):
1034
+ """Save current game state to a named slot.
1035
+
1036
+ Body: {slot_name, genre, step, health, history, game_over}
1037
+ """
1038
+ slot_name = payload.get("slot_name", "autosave")
1039
+ # Sanitize slot name for filesystem
1040
+ safe_name = "".join(c for c in slot_name if c.isalnum() or c in "-_ ").strip()
1041
+ if not safe_name:
1042
+ safe_name = "autosave"
1043
+
1044
+ save_data = {
1045
+ "slot_name": safe_name,
1046
+ "genre": payload.get("genre", "fantasy"),
1047
+ "step": int(payload.get("step", 0)),
1048
+ "health": int(payload.get("health", 100)),
1049
+ "history": payload.get("history", []),
1050
+ "game_over": payload.get("game_over", False),
1051
+ "timestamp": __import__("time").time(),
1052
+ }
1053
+
1054
+ save_path = SAVES_DIR / f"{safe_name}.json"
1055
+ save_path.write_text(json.dumps(save_data, indent=2))
1056
+ log.info(f"Game saved to slot: {safe_name}")
1057
+ return {"status": "ok", "slot_name": safe_name, "timestamp": save_data["timestamp"]}
1058
+
1059
+
1060
+ @fastapi_app.get("/api/game/saves")
1061
+ async def game_saves():
1062
+ """List all saved games."""
1063
+ saves = []
1064
+ for f in sorted(SAVES_DIR.glob("*.json")):
1065
+ try:
1066
+ data = json.loads(f.read_text())
1067
+ saves.append({
1068
+ "slot_name": data.get("slot_name", f.stem),
1069
+ "genre": data.get("genre", "unknown"),
1070
+ "step": data.get("step", 0),
1071
+ "health": data.get("health", 0),
1072
+ "timestamp": data.get("timestamp", 0),
1073
+ "game_over": data.get("game_over", False),
1074
+ })
1075
+ except Exception:
1076
+ continue
1077
+ return {"saves": saves}
1078
+
1079
+
1080
+ @fastapi_app.post("/api/game/load")
1081
+ async def game_load(payload: dict):
1082
+ """Load a saved game by slot name.
1083
+
1084
+ Body: {slot_name}
1085
+ """
1086
+ slot_name = payload.get("slot_name", "")
1087
+ safe_name = "".join(c for c in slot_name if c.isalnum() or c in "-_ ").strip()
1088
+ save_path = SAVES_DIR / f"{safe_name}.json"
1089
+
1090
+ if not save_path.exists():
1091
+ return {"status": "error", "message": f"Save '{safe_name}' not found"}
1092
+
1093
+ try:
1094
+ data = json.loads(save_path.read_text())
1095
+ return {
1096
+ "status": "ok",
1097
+ "slot_name": data.get("slot_name", safe_name),
1098
+ "genre": data.get("genre", "fantasy"),
1099
+ "step": data.get("step", 0),
1100
+ "health": data.get("health", 100),
1101
+ "history": data.get("history", []),
1102
+ "game_over": data.get("game_over", False),
1103
+ }
1104
+ except Exception as e:
1105
+ return {"status": "error", "message": str(e)}
1106
+
1107
+
1108
+ @fastapi_app.delete("/api/game/save/{slot_name}")
1109
+ async def game_delete_save(slot_name: str):
1110
+ """Delete a saved game."""
1111
+ safe_name = "".join(c for c in slot_name if c.isalnum() or c in "-_ ").strip()
1112
+ save_path = SAVES_DIR / f"{safe_name}.json"
1113
+
1114
+ if save_path.exists():
1115
+ save_path.unlink()
1116
+ log.info(f"Deleted save: {safe_name}")
1117
+ return {"status": "ok", "deleted": safe_name}
1118
+ return {"status": "error", "message": f"Save '{safe_name}' not found"}
1119
+
1120
+
1121
+ class UserConfig(BaseModel):
1122
+ hf_token: Optional[str] = None
1123
+ model: Optional[str] = None
1124
+
1125
+
1126
+ @fastapi_app.post("/api/config")
1127
+ async def update_config(cfg: UserConfig):
1128
+ with _USER_CONFIG_LOCK:
1129
+ if cfg.hf_token:
1130
+ _USER_CONFIG["hf_token"] = cfg.hf_token.strip() or None
1131
+ if cfg.model and cfg.model.strip():
1132
+ _USER_CONFIG["model"] = cfg.model.strip()
1133
+ current = dict(_USER_CONFIG)
1134
+ return {
1135
+ "status": "ok",
1136
+ "model": current["model"] or TINYBARD_MODEL,
1137
+ "has_token": bool(current["hf_token"]),
1138
+ }
1139
+
1140
+
1141
+ @fastapi_app.get("/api/config")
1142
+ async def get_config():
1143
+ with _USER_CONFIG_LOCK:
1144
+ current = dict(_USER_CONFIG)
1145
+ return {
1146
+ "model": current["model"] or TINYBARD_MODEL,
1147
+ "has_token": bool(current["hf_token"]),
1148
+ }
1149
+
1150
+
1151
+ # Mount static files
1152
+ fastapi_app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
1153
+
1154
+ # Mount Gradio app at /gradio — this creates the API + MCP endpoints
1155
+ gradio_blocks = create_gradio_app()
1156
+ mount_gradio_app(fastapi_app, gradio_blocks, path="/gradio")
1157
+
1158
+ # ---------------------------------------------------------------------------
1159
+ # Entrypoint
1160
+ # ---------------------------------------------------------------------------
1161
+ if __name__ == "__main__":
1162
+ # On HF Spaces, the platform handles the server — just launch Gradio
1163
+ if os.environ.get("SPACE_ID"):
1164
+ log.info("Running on HF Spaces — launching Gradio directly")
1165
+ gradio_blocks.launch(server_name="0.0.0.0", server_port=7860)
1166
+ else:
1167
+ import uvicorn
1168
+ port = int(os.environ.get("PORT", "7860"))
1169
+ log.info(f"Starting TinyBard on port {port}")
1170
+ log.info(f"Frontend: http://localhost:{port}/")
1171
+ log.info(f"Gradio API: http://localhost:{port}/gradio/")
1172
+ log.info(f"MCP schema: http://localhost:{port}/gradio/gradio_api/mcp/schema")
1173
+ uvicorn.run(fastapi_app, host="0.0.0.0", port=port)