Hermes Bot commited on
Commit
140c4d5
·
unverified ·
1 Parent(s): b930474

feat: LLM-driven infinite adventure loop

Browse files

- Single generate_llm_turn() call produces story + choices + health delta
- Infinite gameplay until health <= 0, no fixed step cap
- Procedural fallback preserved during cooldown/failure

Files changed (11) hide show
  1. .gitignore +10 -0
  2. PRD.md +276 -0
  3. README.md +137 -7
  4. __init__.py +1 -0
  5. app.py +550 -0
  6. requirements.txt +18 -0
  7. shared/cedar_copper_tokens.py +151 -0
  8. shared/inference_client.py +179 -0
  9. static/index.html +93 -0
  10. static/main.js +296 -0
  11. static/style.css +500 -0
.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # AINISH Coder Tooling Distribution - Git Ignore Patterns
2
+ __pycache__/
3
+ *.pyc
4
+ venv/
5
+ .venv/
6
+ .DS_Store
7
+ *.log
8
+ tmp/
9
+ models/
10
+ *.gguf
PRD.md ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DOX framework
2
+
3
+ - DOX is a highly performant `llms.txt` hierarchy installed here
4
+ - Agent must follow DOX instructions across any edits
5
+
6
+ ## Purpose
7
+
8
+ - **Name:** Build Small Hackathon 2026 — Team nbiish
9
+ - **Version:** 0.5.0 — Cedar-Copper Edition (HF Inference API)
10
+ - **Aesthetic:** Cedar-copper visual language — sky-to-sunrise palette (water-blue → cedar → copper → sun-amber → birch-cream), biophilic motifs, sky-to-water gradient banners. Shared CSS variables live in `shared/cedar_copper_tokens.py`.
11
+ - **Purpose:** Win prizes across tracks, badges, and sponsor categories by building delightful, useful AI apps that run locally.
12
+ - **UX:** Gradio web apps (gr.Blocks + mount_gradio_app custom frontends), hosted on HF Spaces.
13
+ - **Hack window:** June 5-15, 2026. Deadline: June 15.
14
+
15
+ > This file is the master PRD and stays English-only. Per-project UIs and READMEs may use additional stylistic content for their own artifacts.
16
+
17
+ ## Core Contract
18
+
19
+ - `llms.txt` files are binding work contracts for their subtrees
20
+ - Work products, source materials, instructions, records, assets, and durable docs must stay understandable from the nearest applicable `llms.txt` plus every parent `llms.txt` above it
21
+
22
+ ## Read Before Editing
23
+
24
+ 1. Read the root `llms.txt`
25
+ 2. Identify every file or folder you expect to touch
26
+ 3. Walk from the repository root to each target path
27
+ 4. Read every `llms.txt` found along each route
28
+ 5. If a parent `llms.txt` lists a child `llms.txt` whose scope contains the path, read that child and continue from there
29
+ 6. Use the nearest `llms.txt` as the local contract and parent docs for repo-wide rules
30
+ 7. If docs conflict, the closer doc controls local work details, but no child doc may weaken DOX
31
+
32
+ Do not rely on memory. Re-read the applicable DOX chain in the current session before editing.
33
+
34
+ ## Local Contracts
35
+
36
+ ### Naming & Comments
37
+
38
+ - Descriptive project names: CritterCalm, FocusFriend, TinyBard
39
+ - Docstrings on all public functions. Comments on non-obvious logic.
40
+
41
+ ### Always
42
+
43
+ - Models ≤ 32B total params per project
44
+ - Gradio app hosted as HF Space
45
+ - Local-first (no cloud APIs = Off the Grid badge)
46
+ - GGUF quantized models for local inference
47
+ - Python 3.10+ with pinned requirements
48
+ - Cedar-copper aesthetic consistency across all UIs (palette tokens in `shared/cedar_copper_tokens.py`)
49
+
50
+ ### Never
51
+
52
+ - Cloud API calls in production path
53
+ - Hardcoded secrets or API keys
54
+ - Models > 32B params
55
+ - Default Gradio look without customization attempt
56
+
57
+ ### If
58
+
59
+ - If custom frontend is feasible → use `mount_gradio_app` for Off-Brand badge
60
+ - If model ≤ 4B → tag Tiny Titan eligible
61
+ - If using llama.cpp runtime → tag Llama Champion
62
+ - If fine-tuning is done → publish model to HF Hub
63
+
64
+ ## Infrastructure
65
+
66
+ ### Gradio 6.0 + MCP Server
67
+
68
+ - `gradio.Server` is **NOT** in Gradio 6.0 stable. Use `mount_gradio_app(fastapi_app, blocks, path="/gradio")` instead.
69
+ - MCP server mode: `demo.launch(mcp_server=True)` or `GRADIO_MCP_SERVER=true` env var.
70
+ - Custom frontends: Serve static HTML/CSS/JS via FastAPI, mount Gradio at `/gradio` for API + MCP.
71
+ - `@gradio/client` CDN: `https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js` (ES module, use `type="module"`).
72
+ - Theme parameters: `css`, `head`, `theme` moved from `gr.Blocks(...)` to `app.launch(...)` in Gradio 6.0.
73
+ - Chatbot API: Gradio 6.0 requires `{"role": "user|assistant", "content": "..."}` dicts (not tuples).
74
+
75
+ ### HF Agents CLI
76
+
77
+ - `hf` CLI is installed (v1.18.0). See `skill://hf-cli` for full command reference.
78
+ - Install expert skills: `hf skills add --global` or `hf skills add --claude --global`.
79
+ - Spaces managed via: `hf repos create <name> --type space --space-sdk gradio --public`.
80
+ - Deploy: `git remote add hf https://huggingface.co/spaces/<user>/<space>` then `git push hf main`.
81
+ - HF README metadata: `colorTo` must be one of `[red, yellow, green, blue, indigo, purple, pink, gray]` (no `emerald`/`amber`).
82
+ - HF README metadata: `emoji` must match `/\p{Extended_Pictographic}/u` — only the standard emoji block is allowed; decorative Unicode glyphs (solar/astrological/typographic symbols) fail validation. Use a real emoji.
83
+
84
+ ### Inference Architecture (v0.5+)
85
+
86
+ - **All LLM inference** is now via the **Hugging Face Inference API** (serverless). No more local GGUF, no `llama-cpp-python` compile step.
87
+ - Shared module: `shared/inference_client.py` provides `cooldown_status()`, `cooldown_active()`, `generate()`, and `chat_messages()`.
88
+ - Default model: `Qwen/Qwen2.5-1.5B-Instruct` (free tier, fast, well-suited to chat). Override via `INFERENCE_MODEL`.
89
+ - Per-project model override: `TINYBARD_MODEL`, `FOCUSFRIEND_MODEL`, `CRITTERCALM_MODEL`.
90
+ - **Cooldowns** enforce a per-project minimum gap between inference calls (protects HF/Modal credit budget):
91
+ - `tinybard`: 6s
92
+ - `focusfriend`: 10s
93
+ - `crittercalm`: 12s
94
+ - Override via `TINYBARD_COOLDOWN_SECONDS`, etc., or global `INFERENCE_COOLDOWN_SECONDS`.
95
+ - **Always-fallback:** every LLM call falls back to procedural / template output if inference fails or is in cooldown. No LLM call ever blocks the UX.
96
+ - HF Spaces are the dev/test environment — iterate live at `huggingface.co/spaces/nbiish/{tinybard,focusfriend,crittercalm}` rather than localhost.
97
+
98
+ ### Local Test Environment
99
+
100
+ - Python: miniconda3 (Python 3.12)
101
+ - Gradio: 6.0.0
102
+ - `huggingface_hub` (for Inference API client)
103
+ - Inference is serverless — no local model files needed unless you opt in to local mode
104
+
105
+ ### Local Servers (optional)
106
+
107
+ Local servers were used during v0.4 development for visual inspection. v0.5+ prefers iterating on the live HF Spaces (which use your HF/Modal compute credits). Local servers can still be run for dev:
108
+
109
+ | Project | URL | Stack | HF Space |
110
+ |---|---|---|---|
111
+ | TinyBard | http://localhost:7861/ | FastAPI + Gradio Blocks | nbiish/tinybard |
112
+ | FocusFriend | http://localhost:7862/ | Gradio 6.0 | nbiish/focusfriend |
113
+ | CritterCalm | http://localhost:7863/ | Gradio 6.0 | nbiish/crittercalm |
114
+
115
+ ## Projects
116
+
117
+ ### 1. CritterCalm (Backyard AI)
118
+
119
+ - **Status:** Code complete. Deployed. HF Inference API + cooldowns wired for script generation. OmniVoice voice cloning still requires local install.
120
+ - **Stack:** OmniVoice (0.6B, local optional) + Kokoro TTS (82M, local optional) + Qwen2.5-7B (default) via HF Inference API
121
+ - **Badges:** Off the Grid, Well-Tuned (TBD), Field Notes, Off-Brand
122
+ - **GitHub:** github.com/nbiish/crittercalm
123
+ - **HF Space:** huggingface.co/spaces/nbiish/crittercalm
124
+ - **Standalone repo:** /Volumes/1tb-sandisk/code-external/crittercalm-repo
125
+
126
+ ### 2. FocusFriend (Thousand Token Wood)
127
+
128
+ - **Status:** Code complete. Deployed. HF Inference API + cooldowns wired. Gradio 6 Chatbot dict-format fixed.
129
+ - **Stack:** Qwen2.5-7B (default) via HF Inference API
130
+ - **Badges:** Off-Brand (sun-amber custom theme), Field Notes, Cooldowns badge
131
+ - **GitHub:** github.com/nbiish/focusfriend
132
+ - **HF Space:** huggingface.co/spaces/nbiish/focusfriend
133
+ - **Standalone repo:** /Volumes/1tb-sandisk/code-external/focusfriend-repo
134
+
135
+ ### 3. TinyBard (Thousand Token Wood + Tiny Titan + Llama Champion)
136
+
137
+ - **Status:** Code complete. Deployed. HF Inference API + cooldowns wired. Local test verified (procedural fallback + cooldown UI).
138
+ - **Concept:** ≤4B LLM generates 5-min interactive text adventures in a CRT terminal aesthetic.
139
+ - **Stack:** Qwen2.5-1.5B (default) via HF Inference API + procedural fallback engine
140
+
141
+ ## Work Guidance
142
+
143
+ ### TODO
144
+
145
+ > Keep tasks atomic and testable.
146
+
147
+ #### In Progress
148
+
149
+ - [ ] Test CritterCalm voice cloning pipeline end-to-end
150
+ - [ ] Test FocusFriend all 4 modes (Chat, Focus, Breathe, Meditate) with real model
151
+ - [ ] Record demo videos (2-3 min each)
152
+ - [ ] Post to social media
153
+ - [ ] Write Field Notes blog posts (3 — one per project)
154
+ - [ ] Share agent traces to HF Hub (Sharing is Caring badge)
155
+
156
+ #### Completed
157
+
158
+ - [x] CritterCalm v1 code complete (11 files) — Cedar-copper UI
159
+ - [x] FocusFriend v1 code complete (16 files) — Cedar-copper UI + Gradio 6 dict Chatbot
160
+ - [x] TinyBard v1 code complete (8 files) — LLM + procedural fallback, CRT UI, clean FastAPI JSON
161
+ - [x] GitHub repos created (nbiish/crittercalm, nbiish/focusfriend, nbiish/tinybard)
162
+ - [x] HF Spaces created and deployed (all 3)
163
+ - [x] Monorepo structure with projects/ directory + shared/ aesthetic module
164
+ - [x] INTELLIGENCE.md — full hackathon landscape analysis
165
+ - [x] SUBMISSION_DRAFTS.md — social posts + Field Notes drafts
166
+ - [x] HF CLI installed + skills configured (`hf skills add --global`)
167
+ - [x] llama-cpp-python installed (conda-forge v0.3.16) — for reference; v0.5+ uses HF Inference API
168
+ - [x] Local verification: all 3 apps run on ports 7861/7862/7863
169
+ - [x] TinyBard end-to-end game loop verified (start → choose → next scene)
170
+ - [x] FocusFriend chat verified (user message → Pip reply)
171
+ - [x] CritterCalm UI navigation verified (all 3 tabs render)
172
+ - [x] **v0.5: HF Inference API wired into all 3 apps** (no local GGUF, no build step)
173
+ - [x] **v0.5: Cooldown system** in `shared/inference_client.py` to protect HF/Modal credit budget
174
+ - [x] **v0.5: TinyBard local test** — procedural fallback works when no HF_TOKEN; cooldown UI shows in footer
175
+
176
+ ### Short-term Goals
177
+
178
+ - Iterate on the live HF Spaces (nbiish/tinybard, nbiish/focusfriend, nbiish/crittercalm)
179
+ - Set HF_TOKEN + INFERENCE_MODEL Space secrets to enable real LLM-backed adventures
180
+ - Record demo videos and post to social media
181
+ - Write and publish Field Notes blog posts
182
+ - Share agent traces for Sharing is Caring badge
183
+ - Polish UIs for demo appeal
184
+
185
+ ## Update After Editing
186
+
187
+ Every meaningful change requires a DOX pass before the task is done.
188
+
189
+ Update the closest owning `llms.txt` when a change affects:
190
+
191
+ - purpose, scope, ownership, or responsibilities
192
+ - durable structure, contracts, workflows, or operating rules
193
+ - required inputs, outputs, permissions, constraints, side effects, or artifacts
194
+ - user preferences about behavior, communication, process, organization, or quality
195
+ - `llms.txt` creation, deletion, move, rename, or index contents
196
+
197
+ Update parent docs when parent-level structure, ownership, workflow, or child index changes. Update child docs when parent changes alter local rules. Remove stale or contradictory text immediately. Small edits that do not change behavior or contracts may leave docs unchanged, but the DOX pass still must happen.
198
+
199
+ ## Hierarchy
200
+
201
+ - Root `llms.txt` is the DOX rail: project-wide instructions, global preferences, durable workflow rules, and the top-level Child DOX Index
202
+ - Child `llms.txt` files own domain-specific instructions and their own Child DOX Index
203
+ - Each parent explains what its direct children cover and what stays owned by the parent
204
+ - The closer a doc is to the work, the more specific and practical it must be
205
+
206
+ ## Child Doc Shape
207
+
208
+ - Create a child `llms.txt` when a folder becomes a durable boundary with its own purpose, rules, responsibilities, workflow, materials, or quality standards
209
+ - Work Guidance must reflect the current standards of the project or user instructions; if there are no specific standards or instructions yet, leave it empty
210
+ - Verification must reflect an existing check; if no verification framework exists yet, leave it empty and update it when one exists
211
+
212
+ Default section order:
213
+ - Purpose
214
+ - Ownership
215
+ - Local Contracts
216
+ - Work Guidance
217
+ - Verification
218
+ - Child DOX Index
219
+
220
+ ## Style
221
+
222
+ - Keep docs concise, current, and operational
223
+ - Document stable contracts, not diary entries
224
+ - Put broad rules in parent docs and concrete details in child docs
225
+ - Prefer direct bullets with explicit names
226
+ - Do not duplicate rules across many files unless each scope needs a local version
227
+ - Delete stale notes instead of explaining history
228
+ - Trim obvious statements, repeated rules, misplaced detail, and warnings for risks that no longer exist
229
+
230
+ ## Closeout
231
+
232
+ 1. Re-check changed paths against the DOX chain
233
+ 2. Update nearest owning docs and any affected parents or children
234
+ 3. Refresh every affected Child DOX Index
235
+ 4. Remove stale or contradictory text
236
+ 5. Run existing verification when relevant
237
+ 6. Report any docs intentionally left unchanged and why
238
+
239
+ ## Verification
240
+
241
+ Run local servers to verify apps:
242
+ - TinyBard: `cd projects/tinybard && python app.py` → http://localhost:7861/
243
+ - FocusFriend: `cd projects/focusfriend && python app.py` → http://localhost:7862/
244
+ - CritterCalm: `cd projects/crittercalm && python app.py` → http://localhost:7863/
245
+
246
+ ## Reference
247
+
248
+ - CritterCalm: projects/crittercalm/ + github.com/nbiish/crittercalm
249
+ - FocusFriend: projects/focusfriend/ + github.com/nbiish/focusfriend
250
+ - TinyBard: projects/tinybard/ + github.com/nbiish/tinybard
251
+ - Aesthetic module: shared/cedar_copper_tokens.py
252
+ - Inference client: shared/inference_client.py
253
+ - ML Intern: github.com/huggingface/ml-intern
254
+ - HF Agents CLI: huggingface.co/docs/hub/en/agents-cli
255
+ - Gradio MCP: gradio.app/guides/model-context-protocol
256
+
257
+ ## User Preferences
258
+
259
+ When the user requests a durable behavior change, record it here or in the relevant child `llms.txt`
260
+
261
+ ## Child DOX Index
262
+
263
+ ### projects/crittercalm/
264
+ - Backyard AI track — CritterCalm wildlife sound identifier
265
+ - Stack: OmniVoice + Dolphin-X1-8B + Kokoro TTS
266
+
267
+ ### projects/focusfriend/
268
+ - Thousand Token Wood track — FocusFriend productivity assistant
269
+ - Stack: Gemma 4 12B via llama-cpp-python
270
+
271
+ ### projects/tinybard/
272
+ - Thousand Token Wood + Tiny Titan + Llama Champion tracks
273
+ - Stack: VibeThinker 1.5B + procedural fallback
274
+
275
+ ### shared/
276
+ - Cedar-copper aesthetic tokens and shared utilities
README.md CHANGED
@@ -1,13 +1,143 @@
1
  ---
2
- title: Tinybard
3
- emoji: 🏢
4
- colorFrom: indigo
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.17.3
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: ᐴ TinyBard ᔔ
3
+ emoji: ☀️
4
+ colorFrom: blue
5
+ colorTo: yellow
6
  sdk: gradio
7
+ sdk_version: 6.0.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: apache-2.0
11
+ tags:
12
+ - text-adventure
13
+ - interactive-fiction
14
+ - thousand-token-wood
15
+ - build-small-hackathon
16
+ - tiny-titan
17
+ - off-brand
18
+ - mcp-server
19
+ - anishinaabe
20
+ - solarpunk
21
+ - inference-api
22
+ - cooldowns
23
  ---
24
 
25
+ # ◈──◆──◇ TINYBARD AADIZOOKAAN-AKINOOMAAGEWIN / STORY-TELLING ENGINE ◇──◆──◈
26
+
27
+ > **A small LLM fires five-minute interactive text adventures in a cedar-and-copper CRT terminal.**
28
+ >
29
+ > ᐴ The land remembers the stories. ᔔ ☼ ☘ ≈
30
+
31
+ TinyBard uses FastAPI + `mount_gradio_app` (Gradio 6.0) with a fully custom HTML/CSS/JS frontend, **MCP server mode** enabled, and an **HF Inference API** backend. Every adventure is procedurally generated — rooms, NPCs, items, and branching narratives on the fly.
32
+
33
+ ## ◆ GASHKITOONAN / CAPABILITIES ◈
34
+
35
+ - **◇ Dynamic Adventures ◇** — LLM generates unique story beats for every playthrough
36
+ - **◇ Three Aadizookaanan / Genres ◇** — Aadizookaan (Fantasy), Ish piming (Sci-Fi), Mashkodewaazibi (Cyberpunk)
37
+ - **◇ Misko-Aki / CRT Terminal ◇** — Cedar-copper cabinet, sun-amber phosphor, frost-on-glass scanlines
38
+ - **◇ MCP Kinoomaagewinan / Tools ◇** — `start_game` and `make_choice` exposed as MCP tools
39
+ - **◇ Giiwenaabik / Inference API ◇** — Serverless HF Inference API; no local GGUF, no build step
40
+ - **◇ Asabiikesiwin / Cooldown ◇** — 6s default between inference calls to protect your credit budget
41
+ - **◇ Bmaad-ziibi / Procedural Fallback ◇** — Full engine works without the LLM
42
+ - **◇ Anishinaabe-Solarpunk ◇** — Sky-to-sunrise palette, syllabic framings, biophilic motifs
43
+
44
+ ## ☼ NITAM-AABAJICHIGANAN / PREREQUISITES ◈
45
+
46
+ - Python 3.10+
47
+ - A Hugging Face token (for the Inference API; many small models work anonymously)
48
+ - ~100MB disk, ~256MB RAM — the model is serverless, not local
49
+
50
+ ## ◇ AABAJITOOWINAN / INSTALLATION ◈
51
+
52
+ ```bash
53
+ git clone https://github.com/nbiish/tinybard.git
54
+ cd tinybard
55
+ pip install -r requirements.txt
56
+
57
+ # Optional: pick a model (default: Qwen/Qwen2.5-1.5B-Instruct — small + fast + free)
58
+ export INFERENCE_MODEL="Qwen/Qwen2.5-1.5B-Instruct"
59
+ # Or for the originally-intended VibeThinker 1.5B:
60
+ # export INFERENCE_MODEL="mradermacher/VibeThinker-1.5B-GGUF"
61
+
62
+ # Optional: set the HF token (anonymous works for many models)
63
+ export HF_TOKEN="hf_..."
64
+
65
+ # Optional: tune the cooldown
66
+ export TINYBARD_COOLDOWN_SECONDS=6
67
+
68
+ python app.py
69
+ ```
70
+
71
+ Then open <http://localhost:7860/>.
72
+
73
+ ## ◈ WAABANDA'IWEWIN / EXAMPLES ◇
74
+
75
+ ```text
76
+ ╭─────────────────────────────────────╮
77
+ │ ᐴ AADIZOOKAAN / FANTASY ᔔ │
78
+ ╰─────────────────────────────────────╯
79
+
80
+ You stand before the gates of the Whisperwood. The ancient trees
81
+ hum with a faint violet energy...
82
+
83
+ [ Take the golden key ] [ Drink the mossy vial ] [ Press forward ]
84
+ ```
85
+
86
+ ## ☼ NAANAAGADAWENINDIZOWIN / VERIFICATION ◈
87
+
88
+ ```bash
89
+ curl -X POST http://localhost:7860/api/game/start \
90
+ -H "Content-Type: application/json" \
91
+ -d '{"genre": "fantasy"}'
92
+ ```
93
+
94
+ Returns clean JSON: `{"story", "choices", "health", "step", "game_over", "history"}`.
95
+
96
+ ```bash
97
+ curl http://localhost:7860/api/model_status
98
+ ```
99
+
100
+ Returns: `{"model": "...", "cooldown": {"active": bool, "remaining_seconds": float, "window_seconds": float}}`.
101
+
102
+ ## ◈ MODEL ◇
103
+
104
+ | Model (default) | Size | Purpose | License |
105
+ |---|---|---|---|
106
+ | Qwen2.5-1.5B-Instruct | 1.5B | Interactive story generation | Apache 2.0 |
107
+ | VibeThinker 1.5B | 1.5B | Alternative — also tiny | Apache 2.0 |
108
+
109
+ Override `INFERENCE_MODEL` to any model that supports `chat_completion` on the HF Inference API. The 1.5B defaults fit the **Tiny Titan** badge.
110
+
111
+ ## ◇ MCP KINOOMAAGEWINAN / TOOLS ◈
112
+
113
+ TinyBard runs with `mcp_server=True`, exposing these tools (also available as FastAPI endpoints):
114
+
115
+ - **`/api/game/start`** (POST `{"genre": "fantasy|scifi|cyberpunk"}`) — Start an adventure
116
+ - **`/api/game/choice`** (POST `{choice, genre, step, health, history}`) — Submit a player choice
117
+ - **`/api/model_status`** (GET) — Check the inference model + cooldown state
118
+
119
+ Connect from any MCP client (Claude Desktop, Cursor, etc.) to the SSE endpoint at `/gradio/gradio_api/mcp/`.
120
+
121
+ ## ◇ GIIZHIITAA / BADGE TARGETS ◇
122
+
123
+ - **◆ Tiny Titan** — Model ≤ 1.5B (well under 4B limit)
124
+ - **◆ Off-Brand** — Fully custom FastAPI+Gradio frontend
125
+ - **◆ Field Notes** — Blog post about tiny model interactive fiction
126
+
127
+ ## ☼ GANAWENDAAGWAD / SECURITY ◈
128
+
129
+ PQC standard for any future API keys via the `pqc-secrets` skill (ML-KEM-768 + AES-256-GCM). At present, only the HF token is in flight (read from env var, never written to disk).
130
+
131
+ ## ◇ AABAAJICHIGANAN / COOLDOWNS ◈
132
+
133
+ The `shared/inference_client.py` module enforces per-project cooldowns. Cooldown protects your HF/Modal credit budget from runaway re-rolls. Defaults:
134
+
135
+ - `tinybard`: 6s
136
+ - `focusfriend`: 10s
137
+ - `crittercalm`: 12s
138
+
139
+ Override per project via Space env vars (`TINYBARD_COOLDOWN_SECONDS`, etc.).
140
+
141
+ ---
142
+
143
+ ◈──◆──◇ ☼ TinyBard v1.1 · Cedar Edition · Anishinaabe Solarpunk · Inference API ◇──◆──◈
__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """TinyBard — Micro Interactive Text Adventure Generator."""
app.py ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 re
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Dict, List, Optional
24
+ import threading
25
+
26
+ import gradio as gr
27
+ from fastapi import FastAPI
28
+ from fastapi.responses import HTMLResponse
29
+ from fastapi.staticfiles import StaticFiles
30
+ from gradio import mount_gradio_app
31
+
32
+ # Inference client with cooldown (no local GGUF, no llama-cpp-python build!)
33
+ # Path layout: monorepo/shared/inference_client.py — go up two parents from this file.
34
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
35
+ from shared.inference_client import (
36
+ InferenceResult,
37
+ cooldown_status,
38
+ cooldown_remaining,
39
+ cooldown_active,
40
+ generate as inference_generate,
41
+ chat_messages,
42
+ INFERENCE_MODEL,
43
+ )
44
+
45
+ logging.basicConfig(
46
+ level=logging.INFO,
47
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
48
+ )
49
+ log = logging.getLogger("tinybard")
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Config & Paths
53
+ # ---------------------------------------------------------------------------
54
+ BASE_DIR = Path(__file__).parent
55
+ STATIC_DIR = BASE_DIR / "static"
56
+
57
+ # Use HF Inference API (VibeThinker 1.5B by default — small, fast, free tier).
58
+ # Override via Space env var: INFERENCE_MODEL.
59
+ # Cooldown enforced in shared.inference_client.
60
+ TINYBARD_MODEL = os.environ.get("TINYBARD_MODEL", INFERENCE_MODEL)
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # User-configurable inference (BYO token / model)
64
+ # ---------------------------------------------------------------------------
65
+ _USER_CONFIG_LOCK = threading.Lock()
66
+ _USER_CONFIG: Dict[str, Optional[str]] = {
67
+ "hf_token": None,
68
+ "model": None,
69
+ }
70
+
71
+
72
+ def get_user_hf_token() -> Optional[str]:
73
+ with _USER_CONFIG_LOCK:
74
+ return _USER_CONFIG["hf_token"]
75
+
76
+
77
+ def get_user_model() -> Optional[str]:
78
+ with _USER_CONFIG_LOCK:
79
+ return _USER_CONFIG["model"]
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Llama.cpp Inference Setup
84
+ # ---------------------------------------------------------------------------
85
+ # No local LLM state — every inference call goes through the HF Inference API
86
+ # with cooldown enforcement. Procedural fallback is always available.
87
+
88
+
89
+ def llm_available() -> bool:
90
+ """True if we *might* succeed at an inference call (cooldown not active,
91
+ HF_TOKEN configured, model id is set)."""
92
+ import os
93
+ token = get_user_hf_token() or os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN")
94
+ model = get_user_model() or TINYBARD_MODEL
95
+ # Inference API still works anonymously for some models, so don't gate hard.
96
+ return bool(model) and not cooldown_active("tinybard")
97
+
98
+
99
+ def last_inference_status() -> dict:
100
+ """Snapshot of the current cooldown + model for /api/model_status."""
101
+ return {
102
+ "model": get_user_model() or TINYBARD_MODEL,
103
+ "cooldown": cooldown_status("tinybard"),
104
+ "has_user_token": bool(get_user_hf_token()),
105
+ }
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Procedural Fallback Adventure Engine
110
+ # ---------------------------------------------------------------------------
111
+ GENRES = {
112
+ "fantasy": {
113
+ "start": "You stand before the gates of the Whisperwood. The ancient trees hum with a faint violet energy.",
114
+ "nodes": [
115
+ {
116
+ "story": "A glowing sprite appears, offering a golden key or a mossy vial.",
117
+ "choices": ["Take the golden key", "Drink the mossy vial", "Ignore the sprite and press forward"]
118
+ },
119
+ {
120
+ "story": "You encounter a moss-covered stone golem blocking the path. It speaks in riddles.",
121
+ "choices": ["Answer its riddle with a joke", "Use your golden key if you have it", "Try to climb over it"]
122
+ },
123
+ {
124
+ "story": "You discover a hidden pool reflecting stars that aren't in the sky.",
125
+ "choices": ["Drink from the star pool", "Rest by the shore", "Toss a coin into the water"]
126
+ }
127
+ ],
128
+ "win": "You find the heart of the forest and unlock the ancient relic. You are victorious!",
129
+ "lose": "The energy of the forest overwhelms you. You fade into the whispers of the wood."
130
+ },
131
+ "scifi": {
132
+ "start": "The emergency lights flicker red in the derelict cargo bay of USS Horizon. Gravity is failing.",
133
+ "nodes": [
134
+ {
135
+ "story": "A leaking fuel pipe blocks the corridor ahead. Sparking wires fill the air.",
136
+ "choices": ["Siphon the fuel", "Bypass the circuits", "Wait for the cycle to clear"]
137
+ },
138
+ {
139
+ "story": "An automated security drone activates, targeting you with its laser system.",
140
+ "choices": ["Hack the drone terminal", "Throw scrap metal to distract it", "Run for the airlock"]
141
+ },
142
+ {
143
+ "story": "You reach the main computer terminal. The AI core is corrupt but online.",
144
+ "choices": ["Initiate override protocol", "Ask the AI for help", "Pull the main power breaker"]
145
+ }
146
+ ],
147
+ "win": "You restore life support and secure the escape pod. You survive!",
148
+ "lose": "The hull breaches. You are swept into the cold embrace of outer space."
149
+ },
150
+ "cyberpunk": {
151
+ "start": "Acid rain beats against the neon signs of Sector 9. Your neural interface is glitching.",
152
+ "nodes": [
153
+ {
154
+ "story": "A street dealer offers to patch your wetware for a few credits or a favor.",
155
+ "choices": ["Accept the shady patch", "Decline and buy a neural booster", "Threaten him for info"]
156
+ },
157
+ {
158
+ "story": "A corporate agent corners you in a wet alleyway. He demands your datapad.",
159
+ "choices": ["Upload a virus to his cyber-eyes", "Hand over a fake datapad", "Sprint up the fire escape"]
160
+ },
161
+ {
162
+ "story": "You infiltrate the mainframe room of Shinra-Tech. The security grid is active.",
163
+ "choices": ["Jack in directly", "Use your backup deck", "Short-circuit the access node"]
164
+ }
165
+ ],
166
+ "win": "You upload the corporate secrets to the net. Sector 9 is free. You win!",
167
+ "lose": "Your brain fried due to feedback from the security grid. Game Over."
168
+ }
169
+ }
170
+
171
+
172
+ def generate_procedural_step(genre: str, step: int, health: int, choice: str = "") -> dict:
173
+ """Generate a fallback adventure step without LLM."""
174
+ genre_data = GENRES.get(genre.lower(), GENRES["fantasy"])
175
+
176
+ if step == 0:
177
+ return {
178
+ "story": genre_data["start"],
179
+ "choices": genre_data["nodes"][0]["choices"],
180
+ "health": health,
181
+ "step": 1,
182
+ "game_over": False
183
+ }
184
+
185
+ health_delta = random.choice([-15, 0, 10])
186
+ new_health = max(0, min(100, health + health_delta))
187
+
188
+ if new_health <= 0:
189
+ return {
190
+ "story": f"After choosing: '{choice}'. " + genre_data["lose"],
191
+ "choices": [],
192
+ "health": 0,
193
+ "step": step + 1,
194
+ "game_over": True
195
+ }
196
+
197
+ if step >= 4:
198
+ return {
199
+ "story": f"After choosing: '{choice}'. " + genre_data["win"],
200
+ "choices": [],
201
+ "health": new_health,
202
+ "step": step + 1,
203
+ "game_over": True
204
+ }
205
+
206
+ node = genre_data["nodes"][step % len(genre_data["nodes"])]
207
+ return {
208
+ "story": f"You choose: '{choice}'.\n\n{node['story']}",
209
+ "choices": node["choices"],
210
+ "health": new_health,
211
+ "step": step + 1,
212
+ "game_over": False
213
+ }
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # LLM Generation Logic (HF Inference API + cooldown)
218
+ # ---------------------------------------------------------------------------
219
+ def _parse_messages(genre: str, history: List[Dict[str, str]], next_instruction: str) -> list[Dict[str, str]]:
220
+ """Translate internal history into OpenAI-style chat messages."""
221
+ system = (
222
+ "You are the narrator of an interactive text adventure game. "
223
+ f"Genre: {genre}. Write in the second person ('You...'). "
224
+ "Keep descriptions highly atmospheric but short (under 3 sentences). "
225
+ "Focus on action, mystery, and choice."
226
+ )
227
+ msgs: List[Dict[str, str]] = [{"role": "system", "content": system}]
228
+ for h in (history or []):
229
+ if h.get("role") == "player":
230
+ msgs.append({"role": "user", "content": h["text"]})
231
+ elif h.get("role") == "narrator":
232
+ msgs.append({"role": "assistant", "content": h["text"]})
233
+ msgs.append({"role": "user", "content": next_instruction})
234
+ return msgs
235
+
236
+
237
+ def generate_llm_turn(
238
+ genre: str,
239
+ history: List[Dict[str, str]],
240
+ current_health: int,
241
+ next_instruction: str,
242
+ ) -> dict:
243
+ """Generate one full adventure turn via the LLM.
244
+
245
+ Returns a dict with keys: story, choices, health_delta, game_over.
246
+ health_delta is typically -15, 0, or +10 (the model decides).
247
+ """
248
+ if cooldown_active("tinybard"):
249
+ log.info("tinybard turn skipped (cooldown active)")
250
+ return {}
251
+ system = (
252
+ "You are the narrator of an interactive text adventure game. "
253
+ f"Genre: {genre}. Write in the second person ('You...'). "
254
+ "Keep descriptions highly atmospheric but short (under 3 sentences). "
255
+ "Focus on action, mystery, and choice. "
256
+ "After the story beat, output exactly 3 short distinct player choices on one line, "
257
+ "in the format: 1. <choice> | 2. <choice> | 3. <choice>"
258
+ )
259
+ user = (
260
+ f"Current health: {current_health}/100. "
261
+ f"History so far: {json.dumps(history[-4:])}. "
262
+ f"{next_instruction} "
263
+ "Also state a health delta of -15, 0, or +10 that reflects how risky this turn was."
264
+ )
265
+ try:
266
+ result = inference_generate(
267
+ project="tinybard",
268
+ messages=[
269
+ {"role": "system", "content": system},
270
+ {"role": "user", "content": user},
271
+ ],
272
+ max_new_tokens=220,
273
+ temperature=0.7,
274
+ )
275
+ text = result.text.strip()
276
+ except Exception as e:
277
+ log.warning(f"HF Inference error (fallback to procedural): {e}")
278
+ return {}
279
+
280
+ # Split story and choices
281
+ story = text
282
+ choices = []
283
+ health_delta = 0
284
+ for sep in ["\n1.", "\n1. ", " 1.", " Choices:"]:
285
+ if sep in text:
286
+ parts = text.split(sep, 1)
287
+ story = parts[0].strip()
288
+ rest = parts[1].strip()
289
+ choices = _parse_choices("1. " + rest)
290
+ break
291
+
292
+ if not choices:
293
+ choices = _parse_choices(text)
294
+
295
+ # Extract a simple health delta from the text when possible
296
+ m = re.search(r"health delta\s*[:=]\s*([+-]?\d+)", text, re.IGNORECASE)
297
+ if m:
298
+ try:
299
+ health_delta = int(m.group(1))
300
+ except Exception:
301
+ health_delta = 0
302
+ else:
303
+ health_delta = random.choice([-15, 0, 10])
304
+
305
+ new_health = max(0, min(100, current_health + health_delta))
306
+ game_over = new_health <= 0
307
+ return {
308
+ "story": story,
309
+ "choices": choices[:3] if len(choices) >= 2 else [],
310
+ "health_delta": health_delta,
311
+ "new_health": new_health,
312
+ "game_over": game_over,
313
+ }
314
+
315
+
316
+ # ---------------------------------------------------------------------------
317
+ # Gradio Blocks — API endpoints (exposed as MCP tools)
318
+ # ---------------------------------------------------------------------------
319
+ def create_gradio_app() -> gr.Blocks:
320
+ """Build the Gradio Blocks app with API endpoints for MCP integration."""
321
+
322
+ with gr.Blocks(title="TinyBard API") as blocks:
323
+ # Hidden state — not rendered in UI, used by API
324
+ genre_input = gr.Textbox(label="Genre", visible=False)
325
+ step_input = gr.Number(label="Step", value=0, visible=False)
326
+ health_input = gr.Number(label="Health", value=100, visible=False)
327
+ choice_input = gr.Textbox(label="Choice", visible=False)
328
+ history_input = gr.Textbox(label="History JSON", value="[]", visible=False)
329
+
330
+ # Output fields
331
+ story_output = gr.Textbox(label="Story")
332
+ choices_output = gr.JSON(label="Choices")
333
+ health_output = gr.Number(label="Health")
334
+ step_output = gr.Number(label="Step")
335
+ game_over_output = gr.Checkbox(label="Game Over")
336
+ history_output = gr.Textbox(label="History JSON")
337
+
338
+ def api_start_game(genre: str):
339
+ """Start a new interactive text adventure. Exposed as MCP tool."""
340
+ genre = genre.lower()
341
+ if genre not in ["fantasy", "scifi", "cyberpunk"]:
342
+ genre = "fantasy"
343
+
344
+ # Try LLM first (will skip if cooldown is active)
345
+ instruction = "Narrate the beginning of the adventure. What happens first? Do not offer choices yet."
346
+ turn = generate_llm_turn(genre, [], 100, instruction)
347
+ if not turn:
348
+ result = generate_procedural_step(genre, 0, 100)
349
+ return (
350
+ result["story"], result["choices"], result["health"],
351
+ result["step"], result["game_over"],
352
+ json.dumps(result.get("history", []))
353
+ )
354
+
355
+ history = [{"role": "narrator", "text": turn["story"]}]
356
+ choices = turn["choices"] if len(turn.get("choices", [])) >= 2 else ["Explore the area", "Check your equipment", "Proceed carefully"]
357
+ return (turn["story"], choices[:3], 100, 1, False, json.dumps(history))
358
+
359
+ def api_make_choice(choice: str, genre: str, step: int, health: int, history_json: str):
360
+ """Submit a player choice to advance the story. Exposed as MCP tool."""
361
+ try:
362
+ history = json.loads(history_json)
363
+ except Exception:
364
+ history = []
365
+
366
+ step = int(step)
367
+ health = int(health)
368
+
369
+ history.append({"role": "player", "text": choice})
370
+
371
+ instruction = "Narrate what happens next as a result of the player's choice."
372
+ turn = generate_llm_turn(genre, history, health, instruction)
373
+ if not turn:
374
+ result = generate_procedural_step(genre, step, health, choice)
375
+ return (
376
+ result["story"], result["choices"], result["health"],
377
+ result["step"], result["game_over"],
378
+ json.dumps(result.get("history", history))
379
+ )
380
+
381
+ history.append({"role": "narrator", "text": turn["story"]})
382
+ choices = turn["choices"] if len(turn.get("choices", [])) >= 2 else ["Move forward", "Look around", "Rest a moment"]
383
+
384
+ return (turn["story"], choices[:3], turn["new_health"], step + 1, turn["game_over"], json.dumps(history))
385
+
386
+ # Register API endpoints
387
+ gr.Button("Start Game").click(
388
+ fn=api_start_game,
389
+ inputs=[genre_input],
390
+ outputs=[story_output, choices_output, health_output, step_output, game_over_output, history_output],
391
+ api_name="start_game"
392
+ )
393
+
394
+ gr.Button("Make Choice").click(
395
+ fn=api_make_choice,
396
+ inputs=[choice_input, genre_input, step_input, health_input, history_input],
397
+ outputs=[story_output, choices_output, health_output, step_output, game_over_output, history_output],
398
+ api_name="make_choice"
399
+ )
400
+
401
+ return blocks
402
+
403
+
404
+ def _parse_choices(choices_text: str) -> List[str]:
405
+ """Parse LLM choice output into a list of choices."""
406
+ choices = []
407
+ if "|" in choices_text:
408
+ choices = [c.split(".")[-1].strip() for c in choices_text.split("|")]
409
+ else:
410
+ for line in choices_text.split("\n"):
411
+ if "." in line or any(d in line for d in "123"):
412
+ parts = line.split(".", 1)
413
+ if len(parts) > 1:
414
+ choices.append(parts[1].strip())
415
+ return choices
416
+
417
+
418
+ # ---------------------------------------------------------------------------
419
+ # FastAPI App — Custom frontend + Gradio API
420
+ # ---------------------------------------------------------------------------
421
+ fastapi_app = FastAPI(title="TinyBard", docs_url="/docs")
422
+
423
+
424
+ @fastapi_app.get("/", response_class=HTMLResponse)
425
+ async def homepage():
426
+ """Serve the retro CRT terminal frontend."""
427
+ index_path = STATIC_DIR / "index.html"
428
+ if index_path.exists():
429
+ return index_path.read_text()
430
+ return HTMLResponse("<h1>TinyBard retro terminal under construction!</h1>")
431
+ @fastapi_app.get("/api/model_status")
432
+ async def model_status():
433
+ """Check the inference client + cooldown status."""
434
+ return last_inference_status()
435
+
436
+
437
+ # ---------------------------------------------------------------------------
438
+ # Game Logic — exposed as both FastAPI (clean JSON) and Gradio (MCP)
439
+ # ---------------------------------------------------------------------------
440
+ def _run_turn(choice: str, genre: str, step: int, health: int, history: List[Dict]) -> dict:
441
+ """Single source of truth for one adventure turn.
442
+
443
+ Returns a dict the frontend can consume directly. Used by both the
444
+ FastAPI /api/game/* endpoints and the Gradio MCP tools.
445
+ """
446
+ in_cooldown = cooldown_active("tinybard")
447
+
448
+ if step == 0:
449
+ if in_cooldown:
450
+ return generate_procedural_step(genre, 0, 100)
451
+ instruction = "Narrate the beginning of the adventure. What happens first? Do not offer choices yet."
452
+ turn = generate_llm_turn(genre, [], 100, instruction)
453
+ if not turn:
454
+ return generate_procedural_step(genre, 0, 100)
455
+ history = [{"role": "narrator", "text": turn["story"]}]
456
+ choices = turn["choices"] if len(turn.get("choices", [])) >= 2 else ["Explore the area", "Check your equipment", "Proceed carefully"]
457
+ return {
458
+ "story": turn["story"], "choices": choices[:3], "health": 100,
459
+ "step": 1, "game_over": False, "history": history,
460
+ }
461
+
462
+ if in_cooldown:
463
+ return generate_procedural_step(genre, step, health, choice)
464
+
465
+ history.append({"role": "player", "text": choice})
466
+ instruction = "Narrate what happens next as a result of the player's choice."
467
+ turn = generate_llm_turn(genre, history, health, instruction)
468
+ if not turn:
469
+ return generate_procedural_step(genre, step, health, choice)
470
+ history.append({"role": "narrator", "text": turn["story"]})
471
+ choices = turn["choices"] if len(turn.get("choices", [])) >= 2 else ["Move forward", "Look around", "Rest a moment"]
472
+ return {
473
+ "story": turn["story"], "choices": choices[:3],
474
+ "health": turn["new_health"], "step": step + 1,
475
+ "game_over": turn["game_over"], "history": history,
476
+ }
477
+
478
+
479
+ @fastapi_app.post("/api/game/start")
480
+ async def game_start(payload: dict):
481
+ """Start a new adventure. Returns clean JSON.
482
+
483
+ Body: {"genre": "fantasy|scifi|cyberpunk"}
484
+ """
485
+ genre = (payload.get("genre") or "fantasy").lower()
486
+ if genre not in ["fantasy", "scifi", "cyberpunk"]:
487
+ genre = "fantasy"
488
+ return _run_turn(choice="", genre=genre, step=0, health=100, history=[])
489
+
490
+
491
+ @fastapi_app.post("/api/game/choice")
492
+ async def game_choice(payload: dict):
493
+ """Submit a player choice. Returns clean JSON.
494
+
495
+ Body: {
496
+ "choice": str, "genre": str, "step": int, "health": int,
497
+ "history": [{"role": ..., "text": ...}, ...]
498
+ }
499
+ """
500
+ return _run_turn(
501
+ choice=payload.get("choice", ""),
502
+ genre=payload.get("genre", "fantasy"),
503
+ step=int(payload.get("step", 1)),
504
+ health=int(payload.get("health", 100)),
505
+ history=payload.get("history", []),
506
+ )
507
+
508
+ @fastapi_app.post("/api/config")
509
+ async def update_config(body: dict):
510
+ with _USER_CONFIG_LOCK:
511
+ if body.get("hf_token"):
512
+ _USER_CONFIG["hf_token"] = body["hf_token"].strip() or None
513
+ if body.get("model") and str(body["model"]).strip():
514
+ _USER_CONFIG["model"] = str(body["model"]).strip()
515
+ current = dict(_USER_CONFIG)
516
+ return {
517
+ "status": "ok",
518
+ "model": current["model"] or TINYBARD_MODEL,
519
+ "has_token": bool(current["hf_token"]),
520
+ }
521
+
522
+
523
+ @fastapi_app.get("/api/config")
524
+ async def get_config():
525
+ with _USER_CONFIG_LOCK:
526
+ current = dict(_USER_CONFIG)
527
+ return {
528
+ "model": current["model"] or TINYBARD_MODEL,
529
+ "has_token": bool(current["hf_token"]),
530
+ }
531
+
532
+
533
+ # Mount static files
534
+ fastapi_app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
535
+
536
+ # Mount Gradio app at /gradio — this creates the API + MCP endpoints
537
+ gradio_blocks = create_gradio_app()
538
+ mount_gradio_app(fastapi_app, gradio_blocks, path="/gradio")
539
+
540
+ # ---------------------------------------------------------------------------
541
+ # Exported for HF Spaces Gradio SDK (launches once on import)
542
+ # ---------------------------------------------------------------------------
543
+ app = fastapi_app
544
+
545
+ # ---------------------------------------------------------------------------
546
+ # HF Spaces entrypoint — keep the ASGI server alive
547
+ # ---------------------------------------------------------------------------
548
+ if __name__ == "__main__":
549
+ import uvicorn
550
+ uvicorn.run(app, host="0.0.0.0", port=7860)
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TinyBard — Micro Text Adventure Generator
2
+ # Python 3.10+
3
+ #
4
+ # Inference is via the Hugging Face Inference API (no local GGUF,
5
+ # no llama-cpp-python compile). Cooldown is enforced in
6
+ # `shared/inference_client.py` to protect your credit budget.
7
+ #
8
+ # Set these Space secrets/variables to configure:
9
+ # HF_TOKEN — your HF token (anonymous works for many small models)
10
+ # INFERENCE_MODEL — model id (default: Qwen/Qwen2.5-1.5B-Instruct)
11
+ # TINYBARD_COOLDOWN_SECONDS — gap between inference calls (default 6)
12
+ # INFERENCE_PROVIDER — "hf-inference" (default, free serverless) or paid
13
+ # INFERENCE_MAX_TOKENS — per-call token cap (default 220)
14
+
15
+ gradio>=5.0
16
+ fastapi>=0.110
17
+ huggingface_hub>=0.20
18
+ uvicorn[standard]>=0.27
shared/cedar_copper_tokens.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Cedar-Copper Design Tokens
3
+ ============================
4
+ Shared palette + decorative-glyph module for the build-small-hackathon monorepo.
5
+
6
+ Each project's own CSS/UI embeds the tokens it needs (this file is a
7
+ reference, not imported at runtime).
8
+
9
+ Decorative Unicode glyphs in this file are intentionally NOT used in HF
10
+ Spaces metadata — see llms.txt "HF Agents CLI" notes.
11
+ """
12
+
13
+ # ── DECORATIVE GLYPHS (in-app banners, not HF metadata) ───────────────────────
14
+ PUU = "\u1438" # ᐸ — left frame character
15
+ SHOO = "\u1514" # ᔔ — right frame character
16
+ BII = "\u142A" # ᐪ
17
+ MII = "\u14A1" # ᑡ
18
+ WA = "\u1418" # ᐘ
19
+ GO = "\u1472" # ᑲ
20
+ AN = "\u14B0" # ᒐ
21
+
22
+ SUN_RAY = "\u263C" # ☼
23
+ SUN_BLACK = "\u2600" # ☀
24
+ LEAF = "\u2618" # ☘
25
+ FLOWER = "\u2740" # ❀
26
+ MEDICINE_WHEEL = "\u2697" # ⚗
27
+ ARROW_UP = "\u21B3" # ↳
28
+ CIRCUIT = "\u25C8" # ◈
29
+ DIAMOND = "\u25C6" # ◆
30
+ DIAMOND_O = "\u25C7" # ◇
31
+
32
+ # ── COLOR PALETTE ──────────────────────────────────────────────────────────────
33
+ PALETTE = {
34
+ # Sky and water
35
+ "sky": "#5BA4D9", # light morning sky
36
+ "water": "#1B4965", # deep lake water
37
+ "ice": "#BEE9E8", # spring thaw
38
+ "frost": "#CAF0F8", # winter light
39
+ # Sun
40
+ "sun": "#F2A93B", # rising sun amber
41
+ "sunlight": "#FFB347", # warm ray
42
+ "ember": "#E76F51", # dusk ember
43
+ # Earth
44
+ "birch": "#F5F1E8", # birchbark white
45
+ "terracotta":"#C8553D", # clay red
46
+ "earth": "#8B3A1F", # deep earth / copper
47
+ "moss": "#588157", # forest moss
48
+ "forest": "#3D6A4A", # cedar forest
49
+ "spruce": "#1B4332", # deep spruce (night)
50
+ # Contrast
51
+ "night": "#0F1A2C", # deep night sky
52
+ "ash": "#3A2E2A", # woodsmoke
53
+ "stone": "#A89F91", # river stone
54
+ "ink": "#1A1F2E", # deep ink
55
+ }
56
+
57
+ # ── PROJECT FRAMING ───────────────────────────────────────────────────────────
58
+ def header(title: str, en: str) -> str:
59
+ """Cedar-copper banner header for a project.
60
+
61
+ Usage:
62
+ header("TinyBard", "MICRO TEXT ADVENTURE")
63
+ """
64
+ line = f"{DIAMOND}{CIRCUIT}{DIAMOND_O}"
65
+ return (
66
+ f"\n{line}{CIRCUIT}{PUU} {title} {SHOO} [{en.upper()}] "
67
+ f"{CIRCUIT}{line}\n"
68
+ )
69
+
70
+
71
+ def footer() -> str:
72
+ """Closing banner."""
73
+ return f"\n{DIAMOND_O}{CIRCUIT}{DIAMOND} {SUN_RAY} \u00b7 {LEAF} \u00b7 {WATER_ICON()} \u00b7 {SUN_BLACK} {CIRCUIT}{DIAMOND}{DIAMOND_O}\n"
74
+
75
+
76
+ def WATER_ICON() -> str:
77
+ return "\u2248" # ≈ (approx, like a wave)
78
+
79
+
80
+ # ── THEME CONSTANTS (CSS variables) ──────────────────────────────────────────
81
+ CSS_VARS = f"""
82
+ :root {{
83
+ --cc-sky: {PALETTE['sky']};
84
+ --cc-water: {PALETTE['water']};
85
+ --cc-ice: {PALETTE['ice']};
86
+ --cc-sun: {PALETTE['sun']};
87
+ --cc-sunlight: {PALETTE['sunlight']};
88
+ --cc-ember: {PALETTE['ember']};
89
+ --cc-birch: {PALETTE['birch']};
90
+ --cc-terra: {PALETTE['terracotta']};
91
+ --cc-earth: {PALETTE['earth']};
92
+ --cc-moss: {PALETTE['moss']};
93
+ --cc-forest: {PALETTE['forest']};
94
+ --cc-spruce: {PALETTE['spruce']};
95
+ --cc-night: {PALETTE['night']};
96
+ --cc-ash: {PALETTE['ash']};
97
+ --cc-stone: {PALETTE['stone']};
98
+ --cc-ink: {PALETTE['ink']};
99
+ }}
100
+ """
101
+
102
+
103
+ # ── SHARED CSS SNIPPETS ───────────────────────────────────────────────────────
104
+ def style_banner() -> str:
105
+ """A standard top-of-app banner with cedar-copper styling."""
106
+ return """
107
+ /* === CEDAR-COPPER BANNER ============================================== */
108
+ .cc-banner {
109
+ display: flex;
110
+ align-items: center;
111
+ justify-content: center;
112
+ gap: 0.6em;
113
+ padding: 14px 18px;
114
+ background: linear-gradient(95deg, var(--cc-sky) 0%, var(--cc-water) 100%);
115
+ color: var(--cc-birch);
116
+ border-bottom: 1px solid rgba(255, 179, 71, 0.35);
117
+ font-family: Georgia, 'Iowan Old Style', serif;
118
+ letter-spacing: 0.5px;
119
+ text-shadow: 0 1px 2px rgba(15, 26, 44, 0.35);
120
+ }
121
+ .cc-banner .syll { font-size: 1.4em; opacity: 0.85; }
122
+ .cc-banner .title { font-size: 1.05em; font-weight: 600; }
123
+ .cc-banner .glyph {
124
+ display: inline-block;
125
+ transform: translateY(-1px);
126
+ font-size: 1.1em;
127
+ color: var(--cc-sunlight);
128
+ }
129
+ /* ===================================================================== */
130
+ """
131
+
132
+
133
+ # ── WELCOME TEXT ──────────────────────────────────────────────────────────────
134
+ WELCOME = (
135
+ f"{PUU} Welcome {SHOO} \u2014 The land remembers you. "
136
+ f"{SUN_RAY} {LEAF} {WATER_ICON()}"
137
+ )
138
+
139
+
140
+ # ── CHARACTER (TinyBard, FocusFriend) ────────────────────────────────────────
141
+ PIP_GREETING_CEDAR_COPPER = (
142
+ f"{PUU} Hello, friend {SHOO} \u2014 I am Pip, the friend of the moss "
143
+ f"and the small winds. Sit. Breathe. The sun is in no hurry. {SUN_BLACK}"
144
+ )
145
+
146
+
147
+ if __name__ == "__main__":
148
+ print(header("Build Small Hackathon", "CODER SANCTUARY"))
149
+ print(WELCOME)
150
+ print(PIP_GREETING_CEDAR_COPPER)
151
+ print(footer())
shared/inference_client.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared HF Inference Client + Cooldown
3
+ ======================================
4
+ Lightweight wrapper around `huggingface_hub.InferenceClient` with:
5
+
6
+ - Per-call cooldown to prevent credit burn on live HF Spaces
7
+ - Async-friendly API
8
+ - Auto-fallback to procedural/story-template engines when inference fails
9
+ - Environment-driven config (works in HF Spaces and local)
10
+
11
+ The cooldown model:
12
+ - Each project has its own cooldown window (default 8s for cheap inference APIs)
13
+ - Within a session, after a successful inference, no new call can run until cooldown expires
14
+ - Failed inference does not start a cooldown (allow quick retry)
15
+ - `cooldown_active()` is the public check; FastAPI handlers short-circuit on active cooldown
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import time
21
+ import logging
22
+ import threading
23
+ from dataclasses import dataclass, field
24
+ from typing import Optional, Dict, Any, Callable, List
25
+
26
+ log = logging.getLogger("inference")
27
+
28
+ # ── Environment knobs ─────────────────────────────────────────────────────────
29
+ # Override these in your Space's "Settings → Variables and secrets".
30
+
31
+ # The HF model id used for text generation (VibeThinker 1.5B, Gemma 4 12B, etc.)
32
+ INFERENCE_MODEL = os.environ.get(
33
+ "INFERENCE_MODEL",
34
+ "Qwen/Qwen2.5-1.5B-Instruct", # small, fast, free-tier friendly
35
+ )
36
+
37
+ # Provider: "hf-inference" (free serverless), "together", "fal-ai", "replicate"
38
+ # Free HF inference works for many small models; otherwise use a paid provider.
39
+ INFERENCE_PROVIDER = os.environ.get("INFERENCE_PROVIDER", "hf-inference")
40
+
41
+ # Token — read from HF Space secrets at runtime.
42
+ HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN")
43
+
44
+ # Default cooldown between inferences, in seconds.
45
+ COOLDOWN_SECONDS = float(os.environ.get("INFERENCE_COOLDOWN_SECONDS", "8"))
46
+
47
+ # Per-project override (keyed by app name)
48
+ PROJECT_COOLDOWN_OVERRIDES = {
49
+ "tinybard": float(os.environ.get("TINYBARD_COOLDOWN_SECONDS", "6")),
50
+ "focusfriend": float(os.environ.get("FOCUSFRIEND_COOLDOWN_SECONDS", "10")),
51
+ "crittercalm": float(os.environ.get("CRITTERCALM_COOLDOWN_SECONDS", "12")),
52
+ }
53
+
54
+ # Max tokens to request (keeps costs bounded)
55
+ MAX_NEW_TOKENS = int(os.environ.get("INFERENCE_MAX_TOKENS", "220"))
56
+ # ── Cooldown registry ────────────────────────────────────────────────────────
57
+ @dataclass
58
+ class _CooldownState:
59
+ last_call: float = 0.0
60
+ lock: threading.Lock = field(default_factory=threading.Lock)
61
+ _states: Dict[str, _CooldownState] = {}
62
+ def _state(project: str) -> _CooldownState:
63
+ if project not in _states:
64
+ _states[project] = _CooldownState()
65
+ return _states[project]
66
+ def cooldown_seconds_for(project: str) -> float:
67
+ return PROJECT_COOLDOWN_OVERRIDES.get(project, COOLDOWN_SECONDS)
68
+ def cooldown_active(project: str) -> bool:
69
+ """Return True if the project is currently in cooldown (cannot run inference)."""
70
+ state = _state(project)
71
+ now = time.time()
72
+ if now - state.last_call < cooldown_seconds_for(project):
73
+ return True
74
+ return False
75
+ def cooldown_remaining(project: str) -> float:
76
+ """Seconds left in the cooldown window (0 if not in cooldown)."""
77
+ state = _state(project)
78
+ elapsed = time.time() - state.last_call
79
+ remaining = cooldown_seconds_for(project) - elapsed
80
+ return max(0.0, remaining)
81
+ def cooldown_status(project: str) -> dict:
82
+ """Snapshot of cooldown state for the UI."""
83
+ return {
84
+ "active": cooldown_active(project),
85
+ "remaining_seconds": round(cooldown_remaining(project), 2),
86
+ "window_seconds": cooldown_seconds_for(project),
87
+ }
88
+ def _mark_called(project: str) -> None:
89
+ state = _state(project)
90
+ with state.lock:
91
+ state.last_call = time.time()
92
+ # ── Inference client wrapper ─────────────────────────────────────────────────
93
+ class InferenceResult:
94
+ """A small wrapper so callers don't need to know which API returned text."""
95
+ def __init__(self, text: str, model: str, provider: str, latency_s: float):
96
+ self.text = text
97
+ self.model = model
98
+ self.provider = provider
99
+ self.latency_s = latency_s
100
+
101
+ def __repr__(self) -> str:
102
+ return f"InferenceResult(text={self.text[:50]!r}…, model={self.model!r}, latency={self.latency_s:.2f}s)"
103
+ def _get_client():
104
+ """Lazy-load the InferenceClient to keep boot fast."""
105
+ from huggingface_hub import InferenceClient
106
+ return InferenceClient(
107
+ model=INFERENCE_MODEL,
108
+ token=HF_TOKEN, # provider kwarg removed for hf-inference default
109
+
110
+ )
111
+ def generate(
112
+ project: str,
113
+ messages: List[Dict[str, str]],
114
+ *,
115
+ max_new_tokens: Optional[int] = None,
116
+ temperature: float = 0.7,
117
+ ) -> InferenceResult:
118
+ """Run a chat-style inference call, with cooldown enforcement.
119
+
120
+ `messages` follows OpenAI chat format: [{"role": "user|assistant|system", "content": "..."}].
121
+ Returns InferenceResult with `.text` (string) on success, or raises on failure.
122
+ Caller is responsible for fallback handling.
123
+ """
124
+ if cooldown_active(project):
125
+ remaining = cooldown_remaining(project)
126
+ raise RuntimeError(
127
+ f"cooldown active for {project!r}: {remaining:.1f}s remaining. "
128
+ f"This protects your HF/Modal credit budget."
129
+ )
130
+
131
+ max_new_tokens = max_new_tokens or MAX_NEW_TOKENS
132
+ client = _get_client()
133
+ start = time.time()
134
+ response = client.chat_completion(
135
+ messages=messages,
136
+ max_tokens=max_new_tokens,
137
+ temperature=temperature,
138
+ )
139
+ latency = time.time() - start
140
+ text = response.choices[0].message.content or ""
141
+ text = text.strip()
142
+ _mark_called(project)
143
+ return InferenceResult(
144
+ text=text,
145
+ model=INFERENCE_MODEL,
146
+ provider=INFERENCE_PROVIDER,
147
+ latency_s=latency,
148
+ )
149
+ def force_clear_cooldown(project: str) -> None:
150
+ """Manual escape hatch (e.g. for testing or admin overrides)."""
151
+ _state(project).last_call = 0.0
152
+ # ── Convenience: build messages + format result ──────────────────────────────
153
+ def chat_messages(system: str, user: str, history: Optional[List[Dict[str, str]]] = None) -> List[Dict[str, str]]:
154
+ """Build an OpenAI-style message list with optional prior turns.
155
+
156
+ `history` is in the same [{role, content}, ...] format. New turns are appended.
157
+ """
158
+ msgs: List[Dict[str, str]] = [{"role": "system", "content": system}]
159
+ if history:
160
+ msgs.extend(history)
161
+ msgs.append({"role": "user", "content": user})
162
+ return msgs
163
+ __all__ = [
164
+ "InferenceResult",
165
+ "cooldown_active",
166
+ "cooldown_remaining",
167
+ "cooldown_seconds_for",
168
+ "cooldown_status",
169
+ "force_clear_cooldown",
170
+ "generate",
171
+ "chat_messages",
172
+ "INFERENCE_MODEL",
173
+ "INFERENCE_PROVIDER",
174
+ "MAX_NEW_TOKENS",
175
+ ]
176
+ if __name__ == "__main__":
177
+ # Smoke test
178
+ for p in ("tinybard", "focusfriend", "crittercalm"):
179
+ print(p, "cooldown:", cooldown_status(p))
static/index.html ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>ᐴ TinyBard ᔔ — Aanishinaabe Mikinaak-Aki</title>
7
+ <link rel="stylesheet" href="/static/style.css">
8
+ </head>
9
+ <body>
10
+ <header class="asp-banner">
11
+ <span class="syll">ᐴ</span>
12
+ <span class="glyph">☼</span>
13
+ <span class="title">TINYBARD</span>
14
+ <span class="glyph">☘</span>
15
+ <span class="subtitle">— a fire-fly storyteller in cedar and copper —</span>
16
+ <span class="syll">ᔔ</span>
17
+ </header>
18
+ <div class="crt-container">
19
+ <div class="crt-screen" id="screen">
20
+ <div class="scanlines"></div>
21
+ <div class="vignette"></div>
22
+
23
+ <div class="terminal" id="terminal">
24
+ <div class="terminal-header">
25
+ <span class="prompt">ᐴ TINYBARD v1.0 ᔔ · GIIZHI-AADIZOKED</span>
26
+ <span class="status" id="health-bar">NOOSISKAAZOWIN: <span id="health-val">100</span></span><button id="config-btn" style="float:right;background:none;border:1px solid var(--asp-amber,#FFB347);color:var(--asp-sun,#F2A93B);cursor:pointer;font-family:monospace;font-size:0.75rem;padding:0.15rem 0.4rem;border-radius:2px;">⚙ CONFIG</button>
27
+ </div>
28
+
29
+ <div class="terminal-body" id="output">
30
+ <div class="boot-sequence" id="boot">
31
+ <span class="line">☼ &gt; INITIAINDIZOWIN / INITIALIZING NEURAL INTERFACE...</span>
32
+ <span class="line">☼ &gt; AABAJICHIGANAN / LOADING NARRATIVE ENGINE...</span>
33
+ <span class="line">☼ &gt; GIIWENAABIK / CONNECTING TO GRADIO SERVER...</span>
34
+ <span class="line success">☼ &gt; MII-GIIWETA / CONNECTION ESTABLISHED</span>
35
+ <span class="line">ᐴ &gt; INAABANDA'IWIN / SELECT GENRE TO BEGIN ᔔ</span>
36
+ </div>
37
+ </div>
38
+
39
+ <div class="genre-selector" id="genre-selector">
40
+ <div class="genre-option" data-genre="fantasy">
41
+ <span class="icon">☼</span> AADIZOOKAAN / FANTASY
42
+ </div>
43
+ <div class="genre-option" data-genre="scifi">
44
+ <span class="icon">◈</span> ISHPIMING / SCI-FI
45
+ </div>
46
+ <div class="genre-option" data-genre="cyberpunk">
47
+ <span class="icon">◆</span> MASHKODEWAAZIBI / CYBERPUNK
48
+ </div>
49
+ </div>
50
+
51
+ <div class="choices-container" id="choices" style="display: none;">
52
+ <!-- Choices injected here -->
53
+ </div>
54
+
55
+ <div class="input-line" id="input-line" style="display: none;">
56
+ <span class="prompt-char">ᐴ&gt;</span>
57
+ <input type="text" id="cmd-input" autocomplete="off" spellcheck="false" />
58
+ <span class="cursor">_</span>
59
+ </div>
60
+
61
+ <div class="terminal-footer">
62
+ <span>ᐴ TinyBard · FastAPI + Gradio + MCP · llama.cpp ᔔ</span>
63
+ <span id="model-status">☘ MODEL: AABAJICHIGE / LOADING...</span>
64
+ </div>
65
+ </div>
66
+ </div>
67
+
68
+ <div class="crt-reflection"></div>
69
+ </div>
70
+
71
+ <script type="module" src="/static/main.js"></script>
72
+
73
+ <!-- User config modal -->
74
+ <div id="tb-config-modal" class="tb-modal-overlay" style="display:none;">
75
+ <div class="tb-modal">
76
+ <button class="tb-close" id="tb-config-close">✕</button>
77
+ <h3>⚙ Inference Configuration</h3>
78
+ <p style="font-size:0.8rem;color:var(--asp-moss,#588157);">
79
+ Provide your own HuggingFace token to unlock models that need auth.
80
+ Your token lives in browser memory only — never sent anywhere except api.huggingface.co.
81
+ </p>
82
+ <label for="tb-model-input">Model ID</label>
83
+ <input type="text" id="tb-model-input" placeholder="Qwen/Qwen2.5-1.5B-Instruct" />
84
+
85
+ <label for="tb-token-input">HF Token (optional)</label>
86
+ <input type="password" id="tb-token-input" placeholder="hf_..." />
87
+
88
+ <button id="tb-config-save">Save & Reload</button>
89
+ <span id="tb-config-status" style="margin-left:0.5rem;color:var(--asp-moss,#588157);"></span>
90
+ </div>
91
+ </div>
92
+ </body>
93
+ </html>
static/main.js ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * TinyBard — Frontend Client
3
+ * Connects to the gr.Server backend via @gradio/client.
4
+ * All game state is managed client-side and passed to the API.
5
+ */
6
+
7
+ const GRADIO_CLIENT_URL = window.location.origin;
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Game State
11
+ // ---------------------------------------------------------------------------
12
+ let gameState = {
13
+ genre: "",
14
+ step: 0,
15
+ health: 100,
16
+ history: [],
17
+ gameActive: false
18
+ };
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // DOM refs
22
+ // ---------------------------------------------------------------------------
23
+ const output = document.getElementById("output");
24
+ const choicesEl = document.getElementById("choices");
25
+ const genreSelector = document.getElementById("genre-selector");
26
+ const inputLine = document.getElementById("input-line");
27
+ const cmdInput = document.getElementById("cmd-input");
28
+ const healthVal = document.getElementById("health-val");
29
+ const modelStatus = document.getElementById("model-status");
30
+ const boot = document.getElementById("boot");
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // API client — uses FastAPI clean-JSON endpoints
34
+ // ---------------------------------------------------------------------------
35
+ async function checkModelStatus() {
36
+ try {
37
+ const resp = await fetch(`${GRADIO_CLIENT_URL}/api/model_status`);
38
+ if (!resp.ok) return;
39
+ const s = await resp.json();
40
+ const model = s.model || "inference";
41
+ const cd = s.cooldown || { active: false, remaining_seconds: 0, window_seconds: 0 };
42
+ if (cd.active) {
43
+ modelStatus.textContent = `☘ ${model} / COOLDOWN ${cd.remaining_seconds.toFixed(1)}s`;
44
+ modelStatus.style.color = "var(--asp-ember)";
45
+ } else if (model) {
46
+ modelStatus.textContent = `☘ ${model} / READY`;
47
+ modelStatus.style.color = "var(--asp-sun)";
48
+ } else {
49
+ modelStatus.textContent = "☘ NO MODEL / FALLBACK";
50
+ modelStatus.style.color = "var(--asp-frost)";
51
+ }
52
+ } catch {
53
+ modelStatus.textContent = "☘ MODEL: ?";
54
+ }
55
+ }
56
+
57
+ // Poll model status every 2s so cooldown countdown updates
58
+ setInterval(checkModelStatus, 2000);
59
+ async function apiCall(endpoint, payload) {
60
+ // Use the FastAPI clean-JSON endpoints (returns a dict directly).
61
+ // /api/game/start -> start_game
62
+ // /api/game/choice -> make_choice
63
+ const path = endpoint === "/start_game"
64
+ ? "/api/game/start"
65
+ : "/api/game/choice";
66
+ const resp = await fetch(`${GRADIO_CLIENT_URL}${path}`, {
67
+ method: "POST",
68
+ headers: { "Content-Type": "application/json" },
69
+ body: JSON.stringify(payload),
70
+ });
71
+ if (!resp.ok) {
72
+ throw new Error(`HTTP ${resp.status}`);
73
+ }
74
+ return await resp.json();
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // UI Helpers
79
+ // ---------------------------------------------------------------------------
80
+ function scrollToBottom() {
81
+ output.scrollTop = output.scrollHeight;
82
+ }
83
+
84
+ function appendOutput(html, className = "") {
85
+ const el = document.createElement("div");
86
+ el.className = className;
87
+ el.innerHTML = html;
88
+ output.appendChild(el);
89
+ scrollToBottom();
90
+ }
91
+
92
+ function clearBoot() {
93
+ if (boot) boot.remove();
94
+ }
95
+
96
+ function updateHealth(hp) {
97
+ gameState.health = hp;
98
+ healthVal.textContent = hp;
99
+ if (hp <= 25) healthVal.style.color = "#ff0040";
100
+ else if (hp <= 50) healthVal.style.color = "#ffb000";
101
+ else healthVal.style.color = "#00ff41";
102
+ }
103
+
104
+ function showChoices(choices) {
105
+ choicesEl.innerHTML = "";
106
+ choicesEl.style.display = "flex";
107
+
108
+ choices.forEach((choice, i) => {
109
+ const btn = document.createElement("button");
110
+ btn.className = "choice-btn";
111
+ btn.textContent = choice;
112
+ btn.addEventListener("click", () => handleChoice(choice));
113
+ choicesEl.appendChild(btn);
114
+ });
115
+ }
116
+
117
+ function hideChoices() {
118
+ choicesEl.style.display = "none";
119
+ choicesEl.innerHTML = "";
120
+ }
121
+
122
+ function showInput() {
123
+ inputLine.style.display = "flex";
124
+ cmdInput.focus();
125
+ }
126
+
127
+ function hideInput() {
128
+ inputLine.style.display = "none";
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Game Logic
133
+ // ---------------------------------------------------------------------------
134
+ async function startGame(genre) {
135
+ gameState = { genre, step: 0, health: 100, history: [], gameActive: true };
136
+ genreSelector.style.display = "none";
137
+ clearBoot();
138
+
139
+ appendOutput(`<span class="narrator-prefix">> STARTING ${genre.toUpperCase()} ADVENTURE...</span>`, "line amber");
140
+ updateHealth(100);
141
+
142
+ try {
143
+ const data = await apiCall("/start_game", { genre });
144
+
145
+ gameState.step = data.step || 1;
146
+ gameState.history = data.history || [];
147
+
148
+ const storyEl = document.createElement("div");
149
+ storyEl.className = "story-text";
150
+ storyEl.textContent = data.story;
151
+ output.appendChild(storyEl);
152
+
153
+ if (data.game_over) {
154
+ endGame(data);
155
+ } else {
156
+ showChoices(data.choices);
157
+ }
158
+ } catch (e) {
159
+ appendOutput(`<span class="error">ERROR: ${e.message}</span>`, "line error");
160
+ console.error(e);
161
+ }
162
+ scrollToBottom();
163
+ }
164
+
165
+ async function handleChoice(choice) {
166
+ if (!gameState.gameActive) return;
167
+
168
+ hideChoices();
169
+ appendOutput(`<span class="player-action">> You chose: ${choice}</span>`, "player-action");
170
+
171
+ try {
172
+ const data = await apiCall("/make_choice", {
173
+ choice,
174
+ genre: gameState.genre,
175
+ step: gameState.step,
176
+ health: gameState.health,
177
+ history_json: JSON.stringify(gameState.history)
178
+ });
179
+
180
+ gameState.step = data.step || gameState.step + 1;
181
+ gameState.history = data.history || gameState.history;
182
+ updateHealth(data.health ?? gameState.health);
183
+
184
+ const storyEl = document.createElement("div");
185
+ storyEl.className = "story-text";
186
+ storyEl.textContent = data.story;
187
+ output.appendChild(storyEl);
188
+
189
+ if (data.game_over) {
190
+ endGame(data);
191
+ } else {
192
+ showChoices(data.choices);
193
+ }
194
+ } catch (e) {
195
+ appendOutput(`<span class="error">ERROR: ${e.message}</span>`, "line error");
196
+ console.error(e);
197
+ }
198
+ scrollToBottom();
199
+ }
200
+
201
+ function endGame(data) {
202
+ gameState.gameActive = false;
203
+ hideChoices();
204
+
205
+ const isWin = data.health > 0;
206
+ const className = isWin ? "game-over-win" : "game-over-lose";
207
+ const label = isWin ? "★ VICTORY ★" : "☠ GAME OVER ☠";
208
+
209
+ appendOutput(`<div class="${className}">${label}<br><small>Final Health: ${data.health}</small></div>`, "");
210
+
211
+ // New game button
212
+ const btn = document.createElement("button");
213
+ btn.className = "new-game-btn";
214
+ btn.textContent = "[ NEW ADVENTURE ]";
215
+ btn.addEventListener("click", resetGame);
216
+ choicesEl.style.display = "flex";
217
+ choicesEl.appendChild(btn);
218
+ }
219
+
220
+ function resetGame() {
221
+ output.innerHTML = "";
222
+ hideChoices();
223
+ gameState = { genre: "", step: 0, health: 100, history: [], gameActive: false };
224
+ healthVal.textContent = "100";
225
+ healthVal.style.color = "#00ff41";
226
+ genreSelector.style.display = "flex";
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Event Listeners
231
+ // ---------------------------------------------------------------------------
232
+ document.querySelectorAll(".genre-option").forEach(el => {
233
+ el.addEventListener("click", () => {
234
+ const genre = el.dataset.genre;
235
+ startGame(genre);
236
+ });
237
+ });
238
+
239
+ cmdInput.addEventListener("keydown", (e) => {
240
+ if (e.key === "Enter" && cmdInput.value.trim()) {
241
+ handleChoice(cmdInput.value.trim());
242
+ cmdInput.value = "";
243
+ }
244
+ });
245
+
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // User Config Modal
249
+ // ---------------------------------------------------------------------------
250
+ const configBtn = document.getElementById('config-btn');
251
+ const configModal = document.getElementById('tb-config-modal');
252
+ const configClose = document.getElementById('tb-config-close');
253
+ const configSave = document.getElementById('tb-config-save');
254
+ const modelInput = document.getElementById('tb-model-input');
255
+ const tokenInput = document.getElementById('tb-token-input');
256
+ const configStatus = document.getElementById('tb-config-status');
257
+
258
+ if (configBtn && configModal) {
259
+ configBtn.addEventListener('click', async () => {
260
+ const cfg = await fetch('/api/config').then(r => r.json());
261
+ modelInput.value = cfg.model || '';
262
+ tokenInput.value = '';
263
+ configStatus.textContent = '';
264
+ configModal.style.display = 'flex';
265
+ });
266
+
267
+ configClose.addEventListener('click', () => {
268
+ configModal.style.display = 'none';
269
+ });
270
+
271
+ configModal.addEventListener('click', (e) => {
272
+ if (e.target === configModal) configModal.style.display = 'none';
273
+ });
274
+
275
+ configSave.addEventListener('click', async () => {
276
+ const body = {};
277
+ if (modelInput.value.trim()) body.model = modelInput.value.trim();
278
+ if (tokenInput.value.trim()) body.hf_token = tokenInput.value.trim();
279
+
280
+ const resp = await fetch('/api/config', {
281
+ method: 'POST',
282
+ headers: { 'Content-Type': 'application/json' },
283
+ body: JSON.stringify(body),
284
+ });
285
+ const data = await resp.json();
286
+ configStatus.textContent = data.status === 'ok' ? '✓ Saved' : '✗ Failed';
287
+ configStatus.style.color = data.status === 'ok' ? 'var(--asp-sun)' : 'var(--asp-ember)';
288
+ setTimeout(() => { configModal.style.display = 'none'; }, 800);
289
+ });
290
+ }
291
+
292
+ // Boot
293
+ // ---------------------------------------------------------------------------
294
+ (async () => {
295
+ await checkModelStatus();
296
+ })();
static/style.css ADDED
@@ -0,0 +1,500 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================================
2
+ TinyBard — Anishinaabe Solarpunk CRT Terminal
3
+ ----------------------------------------------------------------------------
4
+ A retro CRT terminal reinterpreted through Anishinaabe + Solarpunk lenses.
5
+ Phosphor glow becomes "fire-fly light"; scanlines become "birch-bark rings";
6
+ the cabinet's amber becomes rising sun; the screen is the lake (Gichigami)
7
+ at dusk, framed in cedar and copper.
8
+ ========================================================================= */
9
+
10
+ /* === ANISHINAABE-SOLARPUNK DESIGN TOKENS ============================== */
11
+ :root {
12
+ /* Solarpunk palette */
13
+ --asp-sky: #5BA4D9;
14
+ --asp-water: #1B4965;
15
+ --asp-ice: #BEE9E8;
16
+ --asp-frost: #CAF0F8;
17
+ --asp-sun: #F2A93B;
18
+ --asp-sunlight: #FFB347;
19
+ --asp-ember: #E76F51;
20
+ --asp-birch: #F5F1E8;
21
+ --asp-terra: #C8553D;
22
+ --asp-earth: #8B3A1F;
23
+ --asp-moss: #588157;
24
+ --asp-forest: #3D6A4A;
25
+ --asp-spruce: #1B4332;
26
+ --asp-night: #0F1A2C;
27
+ --asp-ash: #3A2E2A;
28
+ --asp-stone: #A89F91;
29
+ --asp-ink: #1A1F2E;
30
+
31
+ /* CRT / terminal legacy (re-anchored to solarpunk palette) */
32
+ --green: var(--asp-sun); /* phosphor -> rising sun amber */
33
+ --green-dim: #C97F1E; /* dim sun */
34
+ --green-dark: #5E3A0E; /* cedar-bark */
35
+ --green-glow: rgba(242, 169, 59, 0.18);
36
+ --amber: var(--asp-sunlight);
37
+ --red: var(--asp-ember);
38
+ --bg: var(--asp-night);
39
+ --terminal-bg: linear-gradient(160deg, #0F1A2C 0%, #1A2A1F 100%);
40
+ --scanline-opacity: 0.04;
41
+ }
42
+
43
+ * { margin: 0; padding: 0; box-sizing: border-box; }
44
+
45
+ @font-face {
46
+ font-family: 'MonoFallback';
47
+ src: local('JetBrains Mono'), local('Fira Code'), local('SF Mono'), local('Menlo'), local('monospace');
48
+ }
49
+
50
+ body {
51
+ background:
52
+ radial-gradient(ellipse at top, #1B4965 0%, transparent 60%),
53
+ radial-gradient(ellipse at bottom right, #1B4332 0%, transparent 70%),
54
+ var(--bg);
55
+ font-family: 'MonoFallback', monospace;
56
+ color: var(--asp-birch);
57
+ min-height: 100vh;
58
+ display: flex;
59
+ flex-direction: column;
60
+ align-items: center;
61
+ justify-content: center;
62
+ overflow-x: hidden;
63
+ overflow-y: auto;
64
+ }
65
+
66
+ /* === ANISHINAABE-SOLARPUNK BANNER ===================================== */
67
+ .asp-banner {
68
+ width: 90vw;
69
+ max-width: 900px;
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: center;
73
+ gap: 0.7em;
74
+ padding: 12px 18px;
75
+ margin-top: 18px;
76
+ background: linear-gradient(95deg, var(--asp-sky) 0%, var(--asp-water) 100%);
77
+ color: var(--asp-birch);
78
+ border: 1px solid rgba(255, 179, 71, 0.3);
79
+ border-radius: 10px 10px 0 0;
80
+ font-family: Georgia, 'Iowan Old Style', serif;
81
+ letter-spacing: 0.5px;
82
+ text-shadow: 0 1px 2px rgba(15, 26, 44, 0.45);
83
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
84
+ }
85
+ .asp-banner .syll { font-size: 1.5em; opacity: 0.9; }
86
+ .asp-banner .title { font-size: 1.05em; font-weight: 600; }
87
+ .asp-banner .glyph {
88
+ display: inline-block;
89
+ transform: translateY(-1px);
90
+ font-size: 1.15em;
91
+ color: var(--asp-sunlight);
92
+ }
93
+ .asp-banner .subtitle {
94
+ color: var(--asp-frost);
95
+ font-size: 0.85em;
96
+ font-style: italic;
97
+ opacity: 0.85;
98
+ }
99
+
100
+ /* === CRT MONITOR (cedar-copper cabinet) ================================ */
101
+ .crt-container {
102
+ width: 90vw;
103
+ max-width: 900px;
104
+ height: 75vh;
105
+ max-height: 660px;
106
+ position: relative;
107
+ background:
108
+ linear-gradient(180deg, #8B3A1F 0%, #5E2710 100%);
109
+ border-radius: 18px 18px 30px 30px;
110
+ padding: 22px;
111
+ box-shadow:
112
+ 0 0 80px rgba(91, 164, 217, 0.12),
113
+ inset 0 0 60px rgba(0, 0, 0, 0.5),
114
+ 0 20px 60px rgba(0, 0, 0, 0.8);
115
+ }
116
+
117
+ /* decorative bezel rivets — medicine wheel */
118
+ .crt-container::before,
119
+ .crt-container::after {
120
+ content: "◈";
121
+ position: absolute;
122
+ top: 8px;
123
+ color: var(--asp-sunlight);
124
+ opacity: 0.6;
125
+ font-size: 0.9em;
126
+ }
127
+ .crt-container::before { left: 12px; }
128
+ .crt-container::after { right: 12px; }
129
+
130
+ .crt-screen {
131
+ width: 100%;
132
+ height: 100%;
133
+ background: var(--terminal-bg);
134
+ border-radius: 12px;
135
+ overflow: hidden;
136
+ position: relative;
137
+ border: 2px solid #1B4332;
138
+ box-shadow:
139
+ inset 0 0 80px rgba(27, 73, 50, 0.5),
140
+ inset 0 0 4px rgba(255, 179, 71, 0.2);
141
+ }
142
+
143
+ /* Scanlines — birch-bark rings reinterpreted */
144
+ .scanlines {
145
+ position: absolute;
146
+ top: 0; left: 0; right: 0; bottom: 0;
147
+ background: repeating-linear-gradient(
148
+ 0deg,
149
+ transparent,
150
+ transparent 2px,
151
+ rgba(91, 164, 217, var(--scanline-opacity)) 2px,
152
+ rgba(91, 164, 217, var(--scanline-opacity)) 4px
153
+ );
154
+ pointer-events: none;
155
+ z-index: 10;
156
+ }
157
+
158
+ /* Vignette — horizon glow */
159
+ .vignette {
160
+ position: absolute;
161
+ top: 0; left: 0; right: 0; bottom: 0;
162
+ background: radial-gradient(
163
+ ellipse at 50% 30%,
164
+ rgba(242, 169, 59, 0.05) 0%,
165
+ transparent 50%,
166
+ rgba(15, 26, 44, 0.55) 100%
167
+ );
168
+ pointer-events: none;
169
+ z-index: 10;
170
+ }
171
+
172
+ /* CRT reflection/glare — frost on glass */
173
+ .crt-reflection {
174
+ position: absolute;
175
+ top: 5%;
176
+ left: 5%;
177
+ width: 40%;
178
+ height: 30%;
179
+ background: linear-gradient(
180
+ 135deg,
181
+ rgba(190, 233, 232, 0.05) 0%,
182
+ transparent 100%
183
+ );
184
+ pointer-events: none;
185
+ z-index: 20;
186
+ border-radius: 50%;
187
+ }
188
+
189
+ /* === TERMINAL LAYOUT ================================================== */
190
+ .terminal {
191
+ width: 100%;
192
+ height: 100%;
193
+ display: flex;
194
+ flex-direction: column;
195
+ padding: 15px 20px;
196
+ position: relative;
197
+ z-index: 5;
198
+ color: var(--asp-birch);
199
+ }
200
+
201
+ .terminal-header {
202
+ display: flex;
203
+ justify-content: space-between;
204
+ align-items: center;
205
+ padding-bottom: 10px;
206
+ border-bottom: 1px solid rgba(91, 164, 217, 0.3);
207
+ margin-bottom: 10px;
208
+ font-size: 0.75rem;
209
+ color: var(--asp-frost);
210
+ text-transform: uppercase;
211
+ letter-spacing: 2px;
212
+ font-family: Georgia, serif;
213
+ }
214
+ .terminal-header .status {
215
+ color: var(--asp-sunlight);
216
+ text-shadow: 0 0 8px rgba(242, 169, 59, 0.35);
217
+ }
218
+ #health-val { color: var(--asp-sun); font-weight: bold; }
219
+
220
+ /* === TERMINAL OUTPUT ================================================== */
221
+ .terminal-body {
222
+ flex: 1;
223
+ overflow-y: auto;
224
+ overflow-x: hidden;
225
+ padding-right: 10px;
226
+ font-size: 0.88rem;
227
+ line-height: 1.65;
228
+ text-shadow: 0 0 6px rgba(242, 169, 59, 0.15);
229
+ scroll-behavior: smooth;
230
+ }
231
+ .terminal-body::-webkit-scrollbar { width: 6px; }
232
+ .terminal-body::-webkit-scrollbar-track { background: transparent; }
233
+ .terminal-body::-webkit-scrollbar-thumb {
234
+ background: rgba(91, 164, 217, 0.3);
235
+ border-radius: 3px;
236
+ }
237
+
238
+ .line {
239
+ display: block;
240
+ margin-bottom: 3px;
241
+ opacity: 0;
242
+ animation: typeIn 0.5s forwards;
243
+ }
244
+ .line.success { color: var(--asp-sunlight); }
245
+ .line.error { color: var(--asp-ember); }
246
+ .line.amber { color: var(--asp-sun); }
247
+
248
+ .story-text {
249
+ display: block;
250
+ margin: 14px 0;
251
+ padding: 12px 16px;
252
+ border-left: 2px solid var(--asp-sun);
253
+ background: rgba(91, 164, 217, 0.06);
254
+ border-radius: 0 4px 4px 0;
255
+ white-space: pre-wrap;
256
+ color: var(--asp-birch);
257
+ animation: fadeSlideIn 0.6s ease-out;
258
+ box-shadow: inset 0 0 20px rgba(15, 26, 44, 0.3);
259
+ }
260
+
261
+ .narrator-prefix {
262
+ color: var(--asp-sun);
263
+ font-weight: bold;
264
+ text-shadow: 0 0 8px rgba(242, 169, 59, 0.4);
265
+ }
266
+
267
+ .player-action {
268
+ display: block;
269
+ color: var(--asp-frost);
270
+ margin: 5px 0;
271
+ font-style: italic;
272
+ border-left: 2px dashed rgba(190, 233, 232, 0.4);
273
+ padding-left: 8px;
274
+ }
275
+
276
+ .game-over-win {
277
+ color: var(--asp-sunlight);
278
+ text-shadow: 0 0 20px rgba(255, 179, 71, 0.6);
279
+ font-size: 1.15rem;
280
+ text-align: center;
281
+ padding: 20px;
282
+ animation: pulse 2s infinite;
283
+ font-family: Georgia, serif;
284
+ }
285
+
286
+ .game-over-lose {
287
+ color: var(--asp-ember);
288
+ text-shadow: 0 0 20px rgba(231, 111, 81, 0.5);
289
+ font-size: 1.15rem;
290
+ text-align: center;
291
+ padding: 20px;
292
+ animation: pulse 2s infinite;
293
+ font-family: Georgia, serif;
294
+ }
295
+
296
+ /* === GENRE SELECTOR =================================================== */
297
+ .genre-selector {
298
+ display: flex;
299
+ gap: 15px;
300
+ padding: 15px 0;
301
+ justify-content: center;
302
+ flex-wrap: wrap;
303
+ }
304
+
305
+ .genre-option {
306
+ padding: 12px 25px;
307
+ border: 1px solid rgba(91, 164, 217, 0.4);
308
+ border-radius: 4px;
309
+ cursor: pointer;
310
+ transition: all 0.2s;
311
+ text-transform: uppercase;
312
+ letter-spacing: 1.5px;
313
+ font-size: 0.8rem;
314
+ color: var(--asp-frost);
315
+ background: rgba(15, 26, 44, 0.4);
316
+ }
317
+
318
+ .genre-option:hover {
319
+ background: linear-gradient(95deg, rgba(242, 169, 59, 0.2) 0%, rgba(91, 164, 217, 0.2) 100%);
320
+ color: var(--asp-sunlight);
321
+ border-color: var(--asp-sun);
322
+ text-shadow: 0 0 12px rgba(242, 169, 59, 0.6);
323
+ box-shadow: 0 0 18px rgba(242, 169, 59, 0.3);
324
+ transform: translateY(-1px);
325
+ }
326
+
327
+ .genre-option .icon { margin-right: 8px; font-size: 1.1em; color: var(--asp-sun); }
328
+
329
+ /* === CHOICE BUTTONS =================================================== */
330
+ .choices-container {
331
+ display: flex;
332
+ flex-direction: column;
333
+ gap: 8px;
334
+ padding: 10px 0;
335
+ }
336
+
337
+ .choice-btn {
338
+ display: block;
339
+ padding: 10px 15px;
340
+ border: 1px solid rgba(91, 164, 217, 0.4);
341
+ border-radius: 3px;
342
+ background: transparent;
343
+ color: var(--asp-frost);
344
+ font-family: 'MonoFallback', monospace;
345
+ font-size: 0.82rem;
346
+ cursor: pointer;
347
+ text-align: left;
348
+ transition: all 0.15s;
349
+ text-shadow: 0 0 3px rgba(91, 164, 217, 0.25);
350
+ }
351
+
352
+ .choice-btn:hover {
353
+ background: rgba(91, 164, 217, 0.1);
354
+ border-color: var(--asp-sun);
355
+ color: var(--asp-sunlight);
356
+ padding-left: 25px;
357
+ box-shadow: 0 0 12px rgba(242, 169, 59, 0.2);
358
+ }
359
+
360
+ .choice-btn::before {
361
+ content: '☼ ';
362
+ color: var(--asp-sun);
363
+ margin-right: 4px;
364
+ }
365
+
366
+ /* === INPUT LINE ======================================================= */
367
+ .input-line {
368
+ display: flex;
369
+ align-items: center;
370
+ padding: 10px 0 5px;
371
+ border-top: 1px solid rgba(91, 164, 217, 0.3);
372
+ margin-top: 5px;
373
+ }
374
+
375
+ .prompt-char {
376
+ color: var(--asp-sun);
377
+ margin-right: 8px;
378
+ font-weight: bold;
379
+ text-shadow: 0 0 8px rgba(242, 169, 59, 0.5);
380
+ }
381
+
382
+ #cmd-input {
383
+ flex: 1;
384
+ background: transparent;
385
+ border: none;
386
+ color: var(--asp-birch);
387
+ font-family: 'MonoFallback', monospace;
388
+ font-size: 0.88rem;
389
+ outline: none;
390
+ caret-color: transparent;
391
+ }
392
+
393
+ .cursor {
394
+ color: var(--asp-sun);
395
+ text-shadow: 0 0 6px rgba(242, 169, 59, 0.6);
396
+ animation: blink 0.8s step-end infinite;
397
+ }
398
+
399
+ /* === TERMINAL FOOTER ================================================== */
400
+ .terminal-footer {
401
+ display: flex;
402
+ justify-content: space-between;
403
+ padding-top: 8px;
404
+ border-top: 1px solid rgba(91, 164, 217, 0.2);
405
+ margin-top: 8px;
406
+ font-size: 0.65rem;
407
+ color: var(--asp-stone);
408
+ letter-spacing: 1.5px;
409
+ }
410
+
411
+ #model-status { color: var(--asp-moss); }
412
+
413
+ /* === HEALTH BAR ====================================================== */
414
+ .health-bar-visual {
415
+ width: 100%;
416
+ height: 4px;
417
+ background: rgba(15, 26, 44, 0.6);
418
+ border-radius: 2px;
419
+ margin-top: 5px;
420
+ overflow: hidden;
421
+ border: 1px solid rgba(91, 164, 217, 0.3);
422
+ }
423
+
424
+ .health-bar-fill {
425
+ height: 100%;
426
+ background: linear-gradient(90deg, var(--asp-sun) 0%, var(--asp-sunlight) 100%);
427
+ transition: width 0.5s ease, background-color 0.5s;
428
+ border-radius: 2px;
429
+ box-shadow: 0 0 8px rgba(242, 169, 59, 0.5);
430
+ }
431
+
432
+ .health-bar-fill.low { background: linear-gradient(90deg, var(--asp-ember) 0%, var(--asp-terra) 100%); }
433
+ .health-bar-fill.mid { background: linear-gradient(90deg, var(--asp-sun) 0%, var(--asp-amber, #FFB347) 100%); }
434
+
435
+ /* === NEW GAME BUTTON ================================================= */
436
+ .new-game-btn {
437
+ display: inline-block;
438
+ padding: 10px 22px;
439
+ border: 1px solid rgba(91, 164, 217, 0.4);
440
+ border-radius: 3px;
441
+ background: transparent;
442
+ color: var(--asp-frost);
443
+ font-family: 'MonoFallback', monospace;
444
+ font-size: 0.78rem;
445
+ cursor: pointer;
446
+ text-transform: uppercase;
447
+ letter-spacing: 1.5px;
448
+ margin-top: 10px;
449
+ transition: all 0.2s;
450
+ }
451
+
452
+ .new-game-btn:hover {
453
+ background: linear-gradient(95deg, rgba(242, 169, 59, 0.2) 0%, rgba(91, 164, 217, 0.2) 100%);
454
+ color: var(--asp-sunlight);
455
+ border-color: var(--asp-sun);
456
+ box-shadow: 0 0 16px rgba(242, 169, 59, 0.35);
457
+ }
458
+
459
+ /* === ANIMATIONS ====================================================== */
460
+ @keyframes typeIn {
461
+ from { opacity: 0; transform: translateY(5px); }
462
+ to { opacity: 1; transform: translateY(0); }
463
+ }
464
+ @keyframes fadeSlideIn {
465
+ from { opacity: 0; transform: translateX(-10px); }
466
+ to { opacity: 1; transform: translateX(0); }
467
+ }
468
+ @keyframes blink {
469
+ 50% { opacity: 0; }
470
+ }
471
+ @keyframes pulse {
472
+ 0%, 100% { opacity: 1; }
473
+ 50% { opacity: 0.65; }
474
+ }
475
+
476
+ /* CRT power-on (sun rising) */
477
+ .crt-screen {
478
+ animation: sunRise 0.7s ease-out;
479
+ }
480
+ @keyframes sunRise {
481
+ 0% { opacity: 0; transform: scaleY(0.01); box-shadow: inset 0 0 0 rgba(242, 169, 59, 0); }
482
+ 40% { opacity: 1; transform: scaleY(0.01); }
483
+ 60% { transform: scaleY(1.05); }
484
+ 80% { transform: scaleY(0.98); }
485
+ 100% { transform: scaleY(1); box-shadow: inset 0 0 80px rgba(27, 73, 50, 0.5); }
486
+ }
487
+
488
+ /* === MOBILE ========================================================== */
489
+ @media (max-width: 600px) {
490
+ .crt-container {
491
+ width: 95vw;
492
+ height: 78vh;
493
+ padding: 12px;
494
+ border-radius: 14px;
495
+ }
496
+ .genre-selector { flex-direction: column; gap: 8px; }
497
+ .genre-option { text-align: center; }
498
+ .terminal-body { font-size: 0.78rem; }
499
+ .asp-banner { font-size: 0.85em; }
500
+ }